<?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 Authentication Oauth]]></title><description><![CDATA[Notes on Azure identity and authentication from 20+ years building commercial software — Microsoft Entra ID, OAuth 2.0, API Management, and the architectural tr]]></description><link>https://shubhankarsarkar.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Azure Authentication Oauth</title><link>https://shubhankarsarkar.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 19 Sep 2026 04:27:49 GMT</lastBuildDate><atom:link href="https://shubhankarsarkar.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Architecture and Design]]></title><description><![CDATA[Solution Architecture · Enterprise Architecture (TOGAF 10) · Target-State & Transition Architecture · Microservices · Event-Driven Architecture · Domain-Driven Design · API-Led Integration · Strangler]]></description><link>https://shubhankarsarkar.hashnode.dev/architecture-and-design</link><guid isPermaLink="true">https://shubhankarsarkar.hashnode.dev/architecture-and-design</guid><category><![CDATA[software architecture]]></category><category><![CDATA[System Design]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[architecture]]></category><dc:creator><![CDATA[shubhu1953]]></dc:creator><pubDate>Sat, 12 Sep 2026 21:20:05 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Solution Architecture · Enterprise Architecture (TOGAF 10) · Target-State &amp; Transition Architecture · Microservices · Event-Driven Architecture · Domain-Driven Design · API-Led Integration · Strangler-Fig Migration · C4 Modelling · HLD / LLD · Architecture Decision Records</p>
</blockquote>
<p>Eleven terms, comma-separated, in no particular order. It reads as a keyword list — and interviewers read it that way too, which is why the follow-up question is usually some variant of <em>"pick one and tell me what you actually did with it."</em></p>
<p>But they aren't eleven independent skills. On a real modernisation they happen in a specific order, each one answering a question the previous one raised. This post walks that order, and says where each concept earns its keep — and where it doesn't.</p>
<p>The running example throughout: <strong>you have been handed a legacy monolith and asked to modernise it.</strong> That is the engagement most of these words were invented for.</p>
<h2>1 · Where you sit — enterprise vs solution architecture</h2>
<p>The first question is not technical. It is <em>what am I allowed to decide?</em></p>
<p><strong>Enterprise architecture</strong> is the organisation-wide view: the capability map, the technology standards, the principles, the roadmap across many programmes. <strong>Solution architecture</strong> is one solution inside that frame. If you are the solution architect, the EA function is your constraint set and often your approval gate.</p>
<p>Getting this boundary wrong is the most common early mistake. Architects who ignore the EA context design something elegant that gets rejected at a design authority for using a database the organisation has committed to retiring. Architects who over-defer produce a design that satisfies every standard and solves nothing.</p>
<h3>What TOGAF is actually for</h3>
<p>TOGAF 10 gets sneered at, usually by people who have only met it as a certification. Its real value is not the ADM phases — most organisations that "do TOGAF" by running every phase produce enormous documents nobody reads.</p>
<p>Its value is <strong>vocabulary and artefacts</strong>. It gives you agreed words for <em>baseline</em>, <em>target</em>, and <em>transition</em> architecture, and a standard set of deliverables that a governance body already knows how to review. When you present a target-state architecture and a set of transition architectures, a TOGAF-literate design authority knows exactly what it is looking at and what it is being asked to approve.</p>
<p>That is the honest pitch: TOGAF is a shared language for getting decisions made in large organisations. Use the parts that buy you that and skip the rest.</p>
<blockquote>
<p><strong>Worth saying in an interview</strong></p>
<p>"I use TOGAF for the artefact vocabulary and the governance touchpoints, not as a process to follow end to end. Running the full ADM on a single solution is usually ceremony." This lands well because it signals you have used it rather than passed an exam on it.</p>
</blockquote>
<h2>2 · Finding the seams — domain-driven design</h2>
<p>You now know your scope. The next question is <em>where does this monolith naturally come apart?</em></p>
<p>This is where <strong>domain-driven design</strong> does its real work — and it is almost always applied too late. DDD is not a coding style. Its contribution at this stage is the <strong>bounded context</strong>: a boundary inside which a term means exactly one thing.</p>
<p>The classic example is worth using because it is true everywhere. "Customer" in a billing context is an account with a payment method and a credit limit. "Customer" in a support context is a person with a contact history and a sentiment score. Same word, different model, different lifecycle, different team. That is two bounded contexts, and the seam between them is where a service boundary belongs.</p>
<p><strong>Why this matters more than any technology choice:</strong> if you draw service boundaries from the existing database schema or the current team structure instead of from the domain, you get a <em>distributed monolith</em> — services that cannot be deployed independently because every change touches three of them. You have taken on all the operational cost of microservices and none of the benefit. This is the single most common failure mode in modernisation programmes, and it is decided at this step, months before anyone writes a service.</p>
<h3>The practical technique</h3>
<p>Run <strong>event storming</strong> with the people who actually do the work — not their managers. You put domain events on a wall in time order, then cluster them. The clusters are your candidate contexts. It takes a day or two and it is worth more than a month of reading the existing code.</p>
<p>Then draw a <strong>context map</strong> showing how the contexts relate: which is upstream, which is downstream, where you need an anti-corruption layer to stop a legacy model leaking into a clean one.</p>
<h2>3 · Where you are going — target-state architecture</h2>
<p>Now you can describe the destination.</p>
<p>A <strong>target-state architecture</strong> describes the estate as it should be once the work is complete: the contexts as services, the data stores, the integration patterns, the runtime platform, the non-functional characteristics it must hit.</p>
<p>Two rules I would defend in any review:</p>
<p><strong>The target state must be falsifiable.</strong> "Cloud-native, scalable, event-driven" is not a target state, it is an aspiration. A target state names the services, the data ownership, and the specific non-functionals — <em>this service owns order data, exposes it over an API-led contract, and must sustain 400 requests per second at p95 under 200ms.</em> If nobody could ever tell you that you had failed to reach it, you have not described it.</p>
<p><strong>The target state is allowed to be wrong.</strong> It is a hypothesis about a future that is eighteen months away. Write it as a document with a version number and a review date, not a monument.</p>
<h2>4 · How you get there — transition architectures</h2>
<p>This is the step most architects skip, and it is why modernisations stall.</p>
<p>A <strong>transition architecture</strong> is an intermediate state that is <em>itself deployable, operable and valuable</em>. Not a milestone on a Gantt chart — a real architecture you could run the business on if the programme were cancelled tomorrow.</p>
<p>The test is blunt: <strong>if funding stopped at the end of this transition, would the organisation be better off than before it started?</strong> If the answer is no, it is not a transition architecture, it is a partially finished migration. And funding does stop. Priorities change, sponsors move, budgets get cut. A modernisation composed of three well-chosen transition states survives that; one composed of a single eighteen-month leap to the target state does not.</p>
<p>In practice each transition typically moves one or two bounded contexts out, leaves the rest in the monolith, and routes traffic accordingly. The legacy system keeps running throughout.</p>
<blockquote>
<p><strong>The interview version</strong></p>
<p>When someone asks how you approach a migration, the answer that separates you is not "strangler-fig" — everyone says that. It is <em>"I define transition architectures such that each one is independently valuable, because programmes get cancelled and I want every stage to have left the estate better than it found it."</em></p>
</blockquote>
<h2>5 · What shape it takes — microservices, events, and API-led integration</h2>
<p>Only now does the technology shape get decided, and this ordering is deliberate: the shape follows the seams, not the other way round.</p>
<h3>Microservices are an organisational decision first</h3>
<p>Conway's law is not a curiosity, it is a design constraint. Microservices buy you <strong>independent deployability</strong> — and independent deployability is only worth its cost if you have independent <em>teams</em> who need to ship without coordinating.</p>
<p>Three teams and a monolith they can all release weekly? Microservices will make you slower. Twelve teams blocked behind a shared release train? Now the cost is worth paying.</p>
<p>Be honest about that cost: distributed tracing, service discovery, contract versioning, eventual consistency, a deployment pipeline per service, and a much harder debugging story. A modular monolith with clean bounded contexts is frequently the better answer, and saying so in an interview signals judgement rather than fashion.</p>
<h3>Event-driven architecture buys decoupling and charges you in debuggability</h3>
<p>Publishing an event instead of calling an API removes the temporal coupling between producer and consumer. The producer does not know or care who listens. That is genuinely powerful — it is what lets you add a new consumer without touching the producer.</p>
<p>What it costs you is <strong>linear reasoning</strong>. With a synchronous call you can read the code and follow the request. With events you cannot; you have to reconstruct the flow from a trace. Choose it where the decoupling is worth that, and instrument accordingly from day one — correlation IDs threaded through every message, not added after the first incident.</p>
<p>Also decide explicitly between <strong>event notification</strong> (thin message, consumer calls back for detail) and <strong>event-carried state transfer</strong> (fat message, consumer needs nothing else). The second removes a runtime dependency and costs you payload size and staleness. Architects who have not thought about this distinction get caught out by it.</p>
<h3>API-led integration is about contracts, not about REST</h3>
<p><strong>API-led</strong> means the boundary between contexts is an explicit, versioned contract rather than a shared database or a direct table read. That is the whole idea. Whether it is REST, gRPC or GraphQL is a downstream detail.</p>
<p>The discipline that makes it work is contract ownership: the service owning the data owns the contract, consumers code against the contract, and breaking changes go through versioning rather than a coordinated release. The moment a consumer reads the producer's database directly, you no longer have an architecture, you have a shared mutable global.</p>
<h2>6 · Actually moving — strangler-fig migration</h2>
<p>You know the seams, the destination, the route and the shape. Now you move — without a big-bang cutover, because big-bang cutovers of business-critical systems fail at a rate that should end the conversation.</p>
<p>The <strong>strangler-fig</strong> pattern puts a facade in front of the legacy system and moves functionality behind it one piece at a time. Traffic for migrated capability routes to the new service; everything else falls through to the monolith. Both run in parallel, indefinitely if need be.</p>
<h3>The facade is easy. The data is not.</h3>
<p>Every article on strangler-fig spends its time on routing. Routing is a gateway rule. The difficulty is that the monolith's database is one schema with foreign keys across every context, and your new service needs to own its data.</p>
<p>The options, none of them free:</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>How it works</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Synchronise both ways</strong></td>
<td>New service owns its store; changes replicate back to the legacy schema</td>
<td>Two sources of truth during transition; conflict handling</td>
</tr>
<tr>
<td><strong>Legacy stays authoritative</strong></td>
<td>New service reads from legacy, writes through it</td>
<td>Simple, but the new service does not really own its data yet</td>
</tr>
<tr>
<td><strong>Hard cut per context</strong></td>
<td>Migrate the data, repoint everything, delete the old tables</td>
<td>Cleanest end state, needs a real outage window per context</td>
</tr>
</tbody></table>
<p>Pick per context rather than globally, and write down which one you picked and why — which is the next section.</p>
<h3>Have a stopping rule</h3>
<p>Strangler-fig migrations die half-finished more often than they fail outright. A facade, three new services, and a monolith that still owns 70% of the functionality is the worst of both worlds, and it is a stable equilibrium because the remaining pieces are always the hardest.</p>
<p>Decide up front what "done" means and what you will do with the remnant. Sometimes the honest answer is that the last 20% stays in the legacy system permanently, wrapped and left alone. That is a legitimate target state. An unplanned version of it is not.</p>
<h2>7 · Communicating it — C4 and the HLD/LLD pair</h2>
<p>An architecture that cannot be explained does not get approved, and an architecture that is explained differently to each audience does not get built.</p>
<h3>C4 works because it has exactly four zoom levels</h3>
<table>
<thead>
<tr>
<th>Level</th>
<th>Question it answers</th>
<th>Who reads it</th>
</tr>
</thead>
<tbody><tr>
<td><strong>1 · Context</strong></td>
<td>What is this system and who uses it?</td>
<td>Everyone, including non-technical stakeholders</td>
</tr>
<tr>
<td><strong>2 · Container</strong></td>
<td>What are the deployable units and how do they talk?</td>
<td>Architects, senior engineers, ops</td>
</tr>
<tr>
<td><strong>3 · Component</strong></td>
<td>What is inside one container?</td>
<td>The team building that container</td>
</tr>
<tr>
<td><strong>4 · Code</strong></td>
<td>What are the classes?</td>
<td>Almost nobody — generate it if you need it</td>
</tr>
</tbody></table>
<p>The discipline that makes C4 useful is the zoom relationship: each diagram elaborates exactly one box from the level above. That is what stops the sprawling any-shape-any-arrow diagrams that most organisations call architecture. If you take one thing from C4, take that constraint.</p>
<p><strong>In practice you will draw levels 1 and 2 constantly, level 3 for the complicated containers, and level 4 essentially never.</strong></p>
<h3>HLD and LLD, and which one people actually read</h3>
<p>The <strong>high-level design</strong> is the solution-wide document: context, containers, integration patterns, data ownership, non-functional requirements, security model, and the decisions that shaped it. It is what goes to a design authority.</p>
<p>The <strong>low-level design</strong> is per-component: schemas, interface definitions, sequence flows, error handling, retry semantics.</p>
<p>Being realistic about their lifespan changes how you write them. <strong>An HLD is read carefully once, at approval, and then skimmed.</strong> An LLD is read by the team building the thing and then goes stale within a quarter. Neither is where your reasoning survives — which is the last piece.</p>
<h2>8 · Recording why — architecture decision records</h2>
<p>The most valuable document in the set is also the smallest.</p>
<p>An <strong>architecture decision record</strong> is a short file, in the repository, capturing one decision: the context, the options, what was chosen, and what it costs. One decision per record, numbered, immutable. When a decision is reversed you do not edit the old record — you write a new one that supersedes it, and the history stays visible.</p>
<pre><code class="language-markdown"># ADR-014 · Event-carried state transfer for order status

**Status:** Accepted · 2026-03-11 · supersedes ADR-009

## Context
Fulfilment, billing and notifications all need order status. Today each
calls the Orders API on every state change — roughly 40 calls/sec at peak,
and an Orders outage takes all three down with it.

## Decision
Orders publishes a fat OrderStatusChanged event carrying the fields
consumers need. Consumers maintain their own read models.

## Consequences
- Orders is no longer on the critical path for those three services
- Consumers tolerate staleness of up to ~2s
- Payload is larger, and adding a field consumers need is a contract change
- We now have four copies of order status and must accept that

## Alternatives considered
- **Thin event + callback** — keeps payload small but leaves the runtime
  dependency on Orders, which was the problem we set out to solve
- **Shared read replica** — rejected; reintroduces schema coupling
</code></pre>
<p><strong>The section that earns its place is "alternatives considered."</strong> Eighteen months later, someone will propose the thing you already rejected. Without the record they will re-litigate it from scratch, usually badly, and possibly do it. With the record the conversation takes five minutes.</p>
<p>ADRs also do something no other artefact does: they make an architect's reasoning reviewable. A colleague can disagree with a decision <em>and see exactly which assumption they disagree with.</em></p>
<blockquote>
<p><strong>A tell worth knowing</strong></p>
<p>If you are interviewing an architect and want one question that separates depth from vocabulary, ask them to describe a decision they got wrong and how they found out. The people who keep ADRs answer it immediately, because they wrote the superseding record themselves.</p>
</blockquote>
<h2>What this sequence is really for</h2>
<p>The eleven terms are not a toolkit you pick from. They are a chain in which each link answers a question the previous one opened:</p>
<ol>
<li><p><strong>EA / TOGAF</strong> — what am I allowed to decide, and who approves it?</p>
</li>
<li><p><strong>Solution architecture</strong> — what is my scope?</p>
</li>
<li><p><strong>DDD</strong> — where does this system come apart?</p>
</li>
<li><p><strong>Target-state</strong> — what does good look like, specifically?</p>
</li>
<li><p><strong>Transition architectures</strong> — what is the route, and is each step worth shipping alone?</p>
</li>
<li><p><strong>Microservices / EDA / API-led</strong> — what shape, and is the cost justified?</p>
</li>
<li><p><strong>Strangler-fig</strong> — how do we move without a big bang?</p>
</li>
<li><p><strong>C4 / HLD / LLD</strong> — how do we explain it to each audience?</p>
</li>
<li><p><strong>ADRs</strong> — how does the reasoning survive us?</p>
</li>
</ol>
<p>An interviewer asking about any one of these is usually probing whether you know its neighbours. Someone who can explain why bounded contexts must precede service boundaries, or why a transition architecture has to be independently valuable, is demonstrating that they have run the sequence — not that they have read the list.</p>
<p>And if you only adopt one thing from all eleven: <strong>write ADRs.</strong> They are the cheapest artefact here and the only one that is still being read in three years.</p>
]]></content:encoded></item><item><title><![CDATA[Onboarding a Partner to Your API: A Step-by-Step Entra Runbook]]></title><description><![CDATA[Part 6 of 6 in the Entra ID Authentication: A Field Guide series.
Every article on this topic explains the pieces. Very few say what order to do them in, or what you actually put in the email to the p]]></description><link>https://shubhankarsarkar.hashnode.dev/onboarding-a-partner-to-your-api-a-step-by-step-entra-runbook</link><guid isPermaLink="true">https://shubhankarsarkar.hashnode.dev/onboarding-a-partner-to-your-api-a-step-by-step-entra-runbook</guid><dc:creator><![CDATA[shubhu1953]]></dc:creator><pubDate>Sat, 12 Sep 2026 17:53:34 GMT</pubDate><content:encoded><![CDATA[<p><em>Part 6 of 6 in the</em> <em><strong>Entra ID Authentication: A Field Guide</strong></em> <em>series.</em></p>
<p>Every article on this topic explains the pieces. Very few say what order to do them in, or what you actually put in the email to the partner.</p>
<p>This is the runbook: you own an API, a partner service must call it with no user involved, and they will authenticate with a certificate. Eight steps, the commands, the handover pack, a troubleshooting table of the real <code>AADSTS</code> codes, and a rotation procedure that does not require downtime.</p>
<p>It closes with the questions I have been asked about this in interviews, and the shape of an answer that reads as experience rather than certification.</p>
<h2>What you are building</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6aa57fa0ae68813919e6c80d/7e837a9a-b555-42cd-acb0-2931d3fcec33.png" alt="" style="display:block;margin:0 auto" />

<p>Two separate timelines get confused with each other. The registration work happens <strong>once, before anything runs</strong>. The token exchange happens <strong>on every call</strong>.</p>
<p>The certificate is only ever used by the partner to sign an assertion to Entra — it never travels to your gateway or your backend. Your gateway only ever sees a bearer token, which is why the validation policy is the same regardless of how the partner authenticated.</p>
<h2>Step 1 · Pick the registration model</h2>
<p>Decide this first, because everything after depends on it.</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Client app lives in</th>
<th>Use when</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Single-tenant</strong> — the common case</td>
<td>Your tenant. You create and own it.</td>
<td>A handful of known partners. You control the identity, you revoke it, you see it in your sign-in logs.</td>
</tr>
<tr>
<td>Multi-tenant</td>
<td>The partner's own tenant</td>
<td>You are a SaaS with many customers self-onboarding, or the partner insists on owning their identity.</td>
</tr>
</tbody></table>
<p>The rest of this runbook uses <strong>single-tenant</strong>: you create an app registration per partner in your own tenant, they hold a certificate for it. It is simpler to operate and far easier to revoke, and it is what most enterprise partner integrations actually look like.</p>
<h2>Step 2 · Register and expose your API</h2>
<p>Done once for the API, not per partner.</p>
<pre><code class="language-bash"># Create the app registration that represents your API
az ad app create \
  --display-name "orders-api" \
  --sign-in-audience AzureADMyOrg

# Note both values it returns — you need appId and id (the object id)
API_APP_ID="&lt;appId&gt;"
API_OBJ_ID="&lt;id&gt;"

# Give it an Application ID URI. This becomes the token audience.
az ad app update --id $API_APP_ID \
  --identifier-uris "api://orders-api"

# A service principal must exist in the tenant or token requests fail
# with AADSTS500011 — "resource principal not found"
az ad sp create --id $API_APP_ID
</code></pre>
<blockquote>
<p><strong>Do this now or debug it later</strong></p>
<p>Force v2 tokens. If you skip it, Entra issues v1 tokens whose issuer is <code>https://sts.windows.net/{tenantId}/</code>, your <code>validate-jwt</code> expects the <code>/v2.0</code> issuer, and you get a 401 that looks like a signature problem and isn't.</p>
</blockquote>
<pre><code class="language-bash">az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$API_OBJ_ID" \
  --headers "Content-Type=application/json" \
  --body '{"api":{"requestedAccessTokenVersion":2}}'
</code></pre>
<h2>Step 3 · Define app roles</h2>
<p>For service-to-service with no user, permissions are <strong>app roles</strong> with <code>allowedMemberTypes: ["Application"]</code>. These land in the <code>roles</code> claim. Do not use OAuth2 permission scopes here — those are for delegated access and produce <code>scp</code>, which a daemon token will never have.</p>
<p><strong>app-roles.json — generate a fresh GUID for each role id</strong></p>
<pre><code class="language-json">[
  {
    "id": "6f9c1a54-3d7e-4b21-9f0a-2c8d5e7b1a33",
    "allowedMemberTypes": ["Application"],
    "displayName": "Read orders",
    "description": "Read-only access to the orders API.",
    "value": "Orders.Read",
    "isEnabled": true
  },
  {
    "id": "b21e7f08-95c4-42da-8ad6-71f3c0e94d17",
    "allowedMemberTypes": ["Application"],
    "displayName": "Read and write orders",
    "description": "Create and amend orders.",
    "value": "Orders.ReadWrite",
    "isEnabled": true
  }
]
</code></pre>
<pre><code class="language-bash">az ad app update --id $API_APP_ID --app-roles @app-roles.json

# Keep the role GUIDs — you need them to grant the role in step 5
</code></pre>
<h2>Step 4 · Onboard the client, and the certificate</h2>
<p>This is the step where the premise usually goes wrong, so it is worth being precise about who does what.</p>
<blockquote>
<p><strong>You do not generate the certificate</strong></p>
<p><strong>The partner generates the key pair and sends you only the public certificate</strong> — a <code>.cer</code> or <code>.pem</code>. You upload that to their app registration. The private key never leaves their estate, and you never hold material that could impersonate them.</p>
<p>If you generate a <code>.pfx</code> and email it to them, you have created a shared secret with a private key in a mailbox, and you can no longer prove which party made any given call. That is worse than a client secret, not better. If a partner genuinely cannot generate a certificate, use a client secret and be honest that you did — don't ship a private key around.</p>
</blockquote>
<p>What the partner runs on their side, once:</p>
<pre><code class="language-bash"># Partner generates a key pair and a self-signed certificate.
# Self-signed is fine here — Entra trusts it because YOU registered it,
# not because a CA vouched for it.
openssl req -x509 -newkey rsa:2048 -sha256 -days 730 -nodes \
  -keyout acme-orders-client.key \
  -out    acme-orders-client.cer \
  -subj   "/CN=acme-orders-client"

# They keep the .key. They send you ONLY the .cer
</code></pre>
<p>Then you register their client application and attach the certificate they sent:</p>
<pre><code class="language-bash"># One app registration per partner — never share one across partners,
# or you lose the ability to revoke or attribute per partner
az ad app create \
  --display-name "orders-client-acme" \
  --sign-in-audience AzureADMyOrg

CLIENT_APP_ID="&lt;appId&gt;"

# Attach their PUBLIC certificate. --append is important: without it
# this command REPLACES every existing credential on the app.
az ad app credential reset \
  --id $CLIENT_APP_ID \
  --cert @acme-orders-client.cer \
  --append

# The client needs a service principal to be assignable a role
az ad sp create --id $CLIENT_APP_ID
</code></pre>
<h2>Step 5 · Grant the app role</h2>
<p>For application permissions there is no user to consent, so "admin consent" <em>is</em> an app role assignment on the client's service principal. This is the step that puts the <code>roles</code> claim in the token — without it the partner gets a perfectly valid token that your policy then rejects with 403.</p>
<pre><code class="language-bash"># Object ids of the two service principals
CLIENT_SP=$(az ad sp show --id $CLIENT_APP_ID --query id -o tsv)
API_SP=$(az ad sp show --id $API_APP_ID --query id -o tsv)

# Assign Orders.Read (the GUID from app-roles.json)
az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$CLIENT_SP/appRoleAssignments" \
  --headers "Content-Type=application/json" \
  --body "{
    \"principalId\": \"$CLIENT_SP\",
    \"resourceId\":  \"$API_SP\",
    \"appRoleId\":   \"6f9c1a54-3d7e-4b21-9f0a-2c8d5e7b1a33\"
  }"

# Verify — this is what the partner will get in their roles claim
az rest --method GET \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$CLIENT_SP/appRoleAssignments"
</code></pre>
<blockquote>
<p><strong>Grant the narrowest role</strong></p>
<p>Assign <code>Orders.Read</code> and make them come back and ask for <code>Orders.ReadWrite</code>. Each role is a separate assignment you can add or remove independently, which means a partner's permissions are a one-command change with an audit record — not a redeployment.</p>
</blockquote>
<h2>Step 6 · The onboarding pack</h2>
<p>Send exactly this, and nothing else. It contains no secrets, so it can go by normal email.</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value to send</th>
<th>What they do with it</th>
</tr>
</thead>
<tbody><tr>
<td>Tenant ID</td>
<td><code>72f988bf-86f1-41af-91ab-…</code></td>
<td>Goes in the authority URL</td>
</tr>
<tr>
<td>Authority</td>
<td><code>https://login.microsoftonline.com/&lt;tenantId&gt;</code></td>
<td>MSAL <code>WithAuthority</code></td>
</tr>
<tr>
<td>Client ID</td>
<td><code>a1b2c3d4-…</code> (their app registration)</td>
<td>MSAL <code>Create(clientId)</code></td>
</tr>
<tr>
<td>Scope</td>
<td><code>api://orders-api/.default</code></td>
<td><code>AcquireTokenForClient</code> — the literal <code>.default</code>, not a role name</td>
</tr>
<tr>
<td>Roles granted</td>
<td><code>Orders.Read</code></td>
<td>So they can check the <code>roles</code> claim matches what they expect</td>
</tr>
<tr>
<td>Certificate thumbprint</td>
<td><code>AB12CD34EF…</code></td>
<td>Confirms which key you registered, if they hold several</td>
</tr>
<tr>
<td>Certificate expiry</td>
<td><code>2028-09-12</code></td>
<td>Both sides diary it — see rotation below</td>
</tr>
<tr>
<td>API base URL</td>
<td><code>https://api.contoso.com/orders/v1</code></td>
<td>Your APIM gateway, not the backend</td>
</tr>
</tbody></table>
<p><strong>Never send:</strong> a <code>.pfx</code>, a private key, a client secret, or the backend's direct URL.</p>
<h2>Step 7 · The APIM policy fragment</h2>
<p>A <strong>policy fragment</strong> is an APIM object holding a reusable block of policy. Author it once, include it from every API that needs partner token validation, and fix a bug in one place. Create it under <em>APIM → Policy fragments → Add</em>, or via Bicep.</p>
<p><strong>Policy fragment · name: validate-partner-token</strong></p>
<pre><code class="language-xml">&lt;fragment&gt;
  &lt;!-- {{named-values}} come from APIM Named values, so tenant and
       audience are not hard-coded per environment --&gt;
  &lt;validate-jwt
      header-name="Authorization"
      failed-validation-httpcode="401"
      failed-validation-error-message="Unauthorized. Supply a valid bearer token."
      require-expiration-time="true"
      require-signed-tokens="true"
      clock-skew="60"
      output-token-variable-name="jwt"&gt;

    &lt;openid-config url="https://login.microsoftonline.com/{{tenant-id}}/v2.0/.well-known/openid-configuration" /&gt;

    &lt;audiences&gt;
      &lt;audience&gt;{{orders-api-audience}}&lt;/audience&gt;
    &lt;/audiences&gt;

    &lt;issuers&gt;
      &lt;issuer&gt;https://login.microsoftonline.com/{{tenant-id}}/v2.0&lt;/issuer&gt;
    &lt;/issuers&gt;
  &lt;/validate-jwt&gt;

  &lt;!-- A daemon token MUST carry app roles. No roles claim means either
       the role was never granted, or a delegated token arrived by mistake. --&gt;
  &lt;choose&gt;
    &lt;when condition='@(!((Jwt)context.Variables["jwt"]).Claims.ContainsKey("roles"))'&gt;
      &lt;return-response&gt;
        &lt;set-status code="403" reason="Forbidden" /&gt;
        &lt;set-header name="Content-Type" exists-action="override"&gt;
          &lt;value&gt;application/json&lt;/value&gt;
        &lt;/set-header&gt;
        &lt;set-body&gt;{"error":"no_application_role","detail":"Token carries no app role. An administrator must grant one."}&lt;/set-body&gt;
      &lt;/return-response&gt;
    &lt;/when&gt;
  &lt;/choose&gt;

  &lt;!-- Attribute every call to a partner in your logs --&gt;
  &lt;set-variable name="callerAppId"
       value='@(((Jwt)context.Variables["jwt"]).Claims.GetValueOrDefault("azp","unknown"))' /&gt;
&lt;/fragment&gt;
</code></pre>
<p><strong>API-level policy · include the fragment</strong></p>
<pre><code class="language-xml">&lt;policies&gt;
  &lt;inbound&gt;
    &lt;base /&gt;
    &lt;include-fragment fragment-id="validate-partner-token" /&gt;
  &lt;/inbound&gt;
&lt;/policies&gt;
</code></pre>
<p><strong>Operation-level policy · require a specific role on a write</strong></p>
<pre><code class="language-xml">&lt;policies&gt;
  &lt;inbound&gt;
    &lt;base /&gt;   &lt;!-- runs the API-level policy, including the fragment --&gt;
    &lt;choose&gt;
      &lt;when condition='@(!((Jwt)context.Variables["jwt"])
             .Claims["roles"].Contains("Orders.ReadWrite"))'&gt;
        &lt;return-response&gt;
          &lt;set-status code="403" reason="Insufficient permission" /&gt;
        &lt;/return-response&gt;
      &lt;/when&gt;
    &lt;/choose&gt;
  &lt;/inbound&gt;
&lt;/policies&gt;
</code></pre>
<blockquote>
<p><strong>Why</strong> <code>output-token-variable-name</code> <strong>matters</strong></p>
<p>It parks the parsed token in <code>context.Variables["jwt"]</code> as a <code>Jwt</code> object, so later policies read claims without re-parsing the header. Without it every subsequent check has to call <code>.AsJwt()</code> on the raw header again — which works, but is easy to get wrong when the header is missing.</p>
</blockquote>
<blockquote>
<p><strong>Note the single-quoted attributes</strong></p>
<p>Every <code>condition</code> above is delimited with <code>'single quotes'</code>, not double. A policy expression is C#, and C# string literals need double quotes — so if you delimit the XML attribute with <code>"</code> you have to escape every inner quote, and the expression becomes genuinely hard to read and review. Single-quoted attributes are perfectly valid XML, APIM accepts them, and the expression stays legible. Worth adopting as a house convention before your policies get long.</p>
</blockquote>
<h2>Step 8 · Test it end to end</h2>
<p>Prove each hop separately. Most integration pain is not knowing which hop failed.</p>
<ol>
<li><p><strong>Can the client get a token at all?</strong> Have the partner run their MSAL call and print the raw token. If this fails, the problem is the certificate or the app registration — your API and APIM are not involved yet.</p>
</li>
<li><p><strong>Does the token look right?</strong> Paste it into <a href="https://jwt.ms">jwt.ms</a> and check four things: <code>iss</code> ends in <code>/v2.0</code>, <code>aud</code> is <code>api://orders-api</code>, a <code>roles</code> array is present with the expected value, and there is no <code>scp</code> claim.</p>
</li>
<li><p><strong>Does APIM accept it?</strong> Call through the gateway with that token. A 401 here is issuer, audience or signature. A 403 is the roles check.</p>
</li>
<li><p><strong>Does the backend receive the call?</strong> Check APIM trace and your backend logs. If APIM passed but the backend rejected, your backend is doing its own validation and needs the same audience and issuer configuration.</p>
</li>
</ol>
<pre><code class="language-bash"># Quick smoke test you can run yourself before involving the partner.
# Uses your own signed-in identity, so it proves the API and APIM config
# independently of the certificate.
TOKEN=$(az account get-access-token \
  --resource "api://orders-api" \
  --query accessToken -o tsv)

curl -i "https://api.contoso.com/orders/v1/orders" \
  -H "Authorization: Bearer $TOKEN"
</code></pre>
<blockquote>
<p><strong>What that smoke test does not prove</strong></p>
<p>Your own token is a <em>user</em> token — it carries <code>scp</code>, not <code>roles</code>. So it will pass <code>validate-jwt</code> and then be rejected by the roles check with 403. That 403 is the correct result and confirms both halves of the fragment are working. Don't "fix" it by relaxing the roles check.</p>
</blockquote>
<h2>Troubleshooting</h2>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>AADSTS700027</code></td>
<td>Client assertion signature invalid, or <code>aud</code> wrong</td>
<td>Assertion <code>aud</code> must be the <em>token endpoint</em>. Confirm the registered cert matches the signing key.</td>
</tr>
<tr>
<td><code>AADSTS700016</code></td>
<td>Application not found in this tenant</td>
<td>Wrong <code>clientId</code>, or wrong tenant in the authority URL</td>
</tr>
<tr>
<td><code>AADSTS500011</code></td>
<td>Resource principal not found</td>
<td>No service principal for the API — run <code>az ad sp create --id $API_APP_ID</code></td>
</tr>
<tr>
<td><code>AADSTS650057</code></td>
<td>Invalid resource</td>
<td>Scope must be <code>api://orders-api/.default</code>, exactly, including <code>.default</code></td>
</tr>
<tr>
<td><code>AADSTS7000215</code></td>
<td>Invalid client secret</td>
<td>The client is sending a secret, not an assertion — they haven't wired up the certificate</td>
</tr>
<tr>
<td>Token issued, APIM returns <strong>401</strong></td>
<td>v1/v2 issuer mismatch</td>
<td>Set <code>requestedAccessTokenVersion: 2</code>, or list <code>https://sts.windows.net/{tid}/</code> as a second issuer</td>
</tr>
<tr>
<td>Token issued, APIM returns <strong>403</strong> <code>no_application_role</code></td>
<td>App role never granted</td>
<td>Step 5 — create the <code>appRoleAssignment</code></td>
</tr>
<tr>
<td>Token has <code>scp</code> not <code>roles</code></td>
<td>A user token, or a delegated scope was defined instead of an app role</td>
<td>Define app roles with <code>allowedMemberTypes: ["Application"]</code></td>
</tr>
<tr>
<td>Worked for months, now 401 everywhere</td>
<td>Certificate expired</td>
<td>Rotation below. Then add expiry monitoring so it doesn't recur.</td>
</tr>
</tbody></table>
<h2>Rotation and revocation</h2>
<p>A certificate integration that has no rotation plan is an outage with a date on it. Two certificates can be registered on one app at the same time, and that overlap is the whole trick.</p>
<ol>
<li><p>Partner generates a new key pair, sends the new <code>.cer</code>, keeps serving traffic with the old key.</p>
</li>
<li><p>You upload it with <code>--append</code>, so the app now trusts <strong>both</strong>. Nothing breaks.</p>
</li>
<li><p>Partner switches their code to sign with the new key and confirms tokens are still issued.</p>
</li>
<li><p>You remove the old certificate. Rotation complete, no downtime, no coordinated cutover.</p>
</li>
</ol>
<p>To <strong>revoke</strong> a partner immediately, in order of increasing severity:</p>
<ul>
<li><p>Remove the app role assignment — they still get a token, but your policy returns 403. Reversible in one command.</p>
</li>
<li><p>Delete the certificate from the app registration — they can no longer get a token at all.</p>
</li>
<li><p>Disable the service principal (<code>accountEnabled: false</code>) — the identity stops working entirely while remaining auditable.</p>
</li>
</ul>
<blockquote>
<p><strong>Set the reminder now</strong></p>
<p>Tokens already issued remain valid until <code>exp</code> — typically up to an hour — so revocation is not instant. If you need immediate cut-off, you need short token lifetimes plus a gateway-level block, not an identity change. And put certificate expiry on a calendar with 60 days' notice on both sides: this is the single most common cause of a working partner integration failing.</p>
</blockquote>
<h2>Questions you will be asked about this</h2>
<h3>A background service needs to read from Microsoft Graph. Walk me through your design.</h3>
<p>Establish whether a user is involved. No user means client credentials with an application permission, or on-behalf-of if a user initiated the work upstream.</p>
<p>Then narrow it. Take the least-privileged Graph permission that does the job, and if it is a broad <code>.All</code> permission, scope it — an application access policy for mailboxes, or a security group filter. State the blast radius out loud: "<code>Mail.Read</code> as an application permission is every mailbox in the tenant, so I'd bound it."</p>
<p>Credential last: managed identity if it runs in Azure, workload identity federation if not, and a Key Vault secret only if neither is possible — with rotation automated, not documented.</p>
<p><strong>What they're testing:</strong> whether you reach for the permission that works or the permission that's correct.</p>
<h3>Why not just store the client secret in Key Vault?</h3>
<p>Because it moves the problem rather than solving it. Reading Key Vault needs a credential, so either you have another secret to protect, or you use a managed identity for that hop — in which case use it for the resource directly and delete the secret.</p>
<p>Managed identities also remove rotation entirely: the platform rotates roughly every 45 days and your code never handles the material. Secrets in a vault still expire, and an expired secret is an outage.</p>
<p><strong>What they're testing:</strong> whether you can identify a bootstrap problem.</p>
<h3>Design authentication for a multi-tenant SaaS product on Azure.</h3>
<p>Multi-tenant app registration in your home tenant; a service principal is created in each customer tenant on consent. Use the v2.0 endpoint, validate <code>tid</code> against your subscriber list on every call, and never treat a valid token as an authorised tenant — token validity and subscription status are separate questions.</p>
<p>Request the minimum scopes so customer admins can consent without a security review stalling your sale; use incremental consent for anything beyond the baseline. Isolate tenant data by <code>tid</code> at the data layer, and decide explicitly between database-per-tenant and shared-with-partition-key.</p>
<p>For consumer-facing identity instead, that's Entra External ID rather than workforce Entra.</p>
<p><strong>What they're testing:</strong> whether you've built multi-tenant or only read about it — the <code>tid</code> validation point is the tell.</p>
<h3>How do you stop a compromised CI pipeline deploying to production?</h3>
<p>The federated credential's subject claim is the control. Pin it to a protected environment with required reviewers, not to a repository or a pull-request trigger — otherwise anyone who can open a PR can obtain the production identity.</p>
<p>Then defence in depth: a separate federated credential and a separate subscription per environment, the deployment identity scoped to deploy rather than to administer, and branch protection with required reviews as the actual human gate. Check the token Entra issues actually carries the identity you expect, rather than assuming the federation mapped the way you intended.</p>
<p><strong>What they're testing:</strong> whether you know that federation moves the trust boundary rather than removing it.</p>
<h3>A partner needs to call our API. They can't use OAuth. What do you do?</h3>
<p>Terminate their credential at the gateway and don't let it near the backend. Mutual TLS into API Management, validated with <code>validate-client-certificate</code> pinned on issuer and subject rather than thumbprint so their renewal doesn't break us. Then APIM uses its own managed identity to get a token for the backend, so the backend trusts exactly one issuer and one audience and knows nothing about partners.</p>
<p>Flag what mTLS does <em>not</em> give you: it authenticates the channel, not the request. Per-request authorisation still has to come from somewhere — a subject-to-permission mapping in policy, or a claim the gateway injects and the backend trusts only because the network path guarantees it.</p>
<p>And name the operational trap: APIM won't receive a certificate at all unless <code>negotiateClientCertificate</code> is set on the hostname, which produces a policy that looks right and 403s everything.</p>
<p><strong>What they're testing:</strong> whether you can design for a constraint you can't change, and whether you volunteer the limitations of your own answer.</p>
<h3>Certificate or client secret for a service-to-service call?</h3>
<p>Neither, if the caller runs in Azure — that's a managed identity, and the question is only live when it can't be. Then: certificate, because the secret is bearer material and the certificate is proof of possession. The private key never crosses the wire; the signed assertion is single-use, audience-bound and valid for minutes.</p>
<p>The detail that shows you've operated it is <code>sendX5C: true</code> and subject name / issuer authentication — Entra trusts any certificate with that subject and issuer, so renewal is transparent and old and new overlap. Thumbprint-bound certificates just relocate the expiry outage.</p>
<p><strong>What they're testing:</strong> whether "use a certificate" is a slogan or something you've actually rotated in production.</p>
<hr />
<p><em>This is the last post in the</em> <em><strong>Entra ID Authentication: A Field Guide</strong></em> <em>series. The earlier posts cover certificate authentication, token validation, OAuth grant types, secretless patterns and API Management policy.</em></p>
]]></content:encoded></item><item><title><![CDATA[Validating Entra Tokens in Azure API Management]]></title><description><![CDATA[Part 5 of 6 in the Entra ID Authentication: A Field Guide series.
A partner sends you a token. It is signed correctly, the audience is right, it has not expired — and API Management returns 401 with n]]></description><link>https://shubhankarsarkar.hashnode.dev/validating-entra-tokens-in-azure-api-management</link><guid isPermaLink="true">https://shubhankarsarkar.hashnode.dev/validating-entra-tokens-in-azure-api-management</guid><category><![CDATA[Azure]]></category><category><![CDATA[api]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[shubhu1953]]></dc:creator><pubDate>Sat, 12 Sep 2026 17:49:56 GMT</pubDate><content:encoded><![CDATA[<p><em>Part 5 of 6 in the</em> <em><strong>Entra ID Authentication: A Field Guide</strong></em> <em>series.</em></p>
<p>A partner sends you a token. It is signed correctly, the audience is right, it has not expired — and API Management returns 401 with nothing useful in the message.</p>
<p>Nine times out of ten this is the v1/v2 issuer mismatch, and it is entirely invisible unless you know to look for it. This post covers the <code>validate-jwt</code> policy properly, how to enforce app roles per operation, client certificate validation for callers who cannot do OAuth, and the two-hop pattern that keeps partner credentials away from your backend.</p>
<h2>Validating that token in API Management</h2>
<p>The token arrives at your gateway. <code>validate-jwt</code> is where it gets checked — before any request reaches your backend, and without a line of code in the API itself.</p>
<p><strong>Inbound policy — app-only (daemon) caller</strong></p>
<pre><code class="language-xml">&lt;policies&gt;
  &lt;inbound&gt;
    &lt;base /&gt;
    &lt;validate-jwt header-name="Authorization"
                  failed-validation-httpcode="401"
                  failed-validation-error-message="Unauthorized"
                  require-expiration-time="true"
                  require-signed-tokens="true"
                  clock-skew="60"&gt;

      &lt;!-- Discovery doc means JWKS key rotation is handled for you --&gt;
      &lt;openid-config url="https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration" /&gt;

      &lt;audiences&gt;
        &lt;audience&gt;api://orders-api&lt;/audience&gt;
      &lt;/audiences&gt;

      &lt;issuers&gt;
        &lt;issuer&gt;https://login.microsoftonline.com/{tenantId}/v2.0&lt;/issuer&gt;
      &lt;/issuers&gt;

      &lt;!-- App-only tokens carry "roles". Delegated tokens carry "scp". --&gt;
      &lt;required-claims&gt;
        &lt;claim name="roles" match="any"&gt;
          &lt;value&gt;Orders.Read&lt;/value&gt;
          &lt;value&gt;Orders.ReadWrite&lt;/value&gt;
        &lt;/claim&gt;
      &lt;/required-claims&gt;
    &lt;/validate-jwt&gt;
  &lt;/inbound&gt;
&lt;/policies&gt;
</code></pre>
<blockquote>
<p><strong>The v1 / v2 issuer mismatch</strong></p>
<p>The single most common cause of a working token being rejected by APIM. If the app registration's <code>accessTokenAcceptedVersion</code> is <code>null</code> or <code>1</code>, Entra issues <strong>v1 tokens whose issuer is</strong> <code>https://sts.windows.net/{tenantId}/</code> — not the <code>login.microsoftonline.com/.../v2.0</code> value you configured. Signature and audience both validate; the issuer doesn't, and the error message won't tell you why. Either set the manifest to version 2, or list both issuers. Being able to diagnose this from a 401 is a strong practical signal.</p>
</blockquote>
<p>To enforce different permissions per operation, scope the check rather than repeating the whole policy — inbound policies compose from API level down to operation level:</p>
<pre><code class="language-xml">&lt;!-- Operation-level policy on DELETE /orders/{id} --&gt;
&lt;inbound&gt;
  &lt;base /&gt;   &lt;!-- inherits the API-level validate-jwt above --&gt;
  &lt;choose&gt;
    &lt;when condition='@(!context.Request.Headers
        .GetValueOrDefault("Authorization","")
        .AsJwt().Claims.GetValueOrDefault("roles","")
        .Contains("Orders.ReadWrite"))'&gt;
      &lt;return-response&gt;
        &lt;set-status code="403" reason="Insufficient permission" /&gt;
      &lt;/return-response&gt;
    &lt;/when&gt;
  &lt;/choose&gt;
&lt;/inbound&gt;
</code></pre>
<p>Note the single-quoted attribute. A policy expression is C#, and C# string literals need double quotes — so if you delimit the XML attribute with <code>"</code> you have to escape every inner quote as <code>&amp;quot;</code>, which makes a policy genuinely hard to review. Single-quoted attributes are valid XML, APIM accepts them, and the expression stays legible.</p>
<h2>Client certificates and mTLS at the gateway</h2>
<p>Some callers can't do OAuth at all — a partner's legacy middleware, a payment network, an on-premises scheduler. Mutual TLS authenticates the <em>channel</em> instead of carrying a token, and APIM validates the presented certificate declaratively.</p>
<p><strong>Declarative validation — preferred</strong></p>
<pre><code class="language-xml">&lt;validate-client-certificate
    validate-revocation="true"
    validate-trust="true"
    validate-not-before="true"
    validate-not-after="true"
    ignore-error="false"&gt;
  &lt;identities&gt;
    &lt;!-- Pins to issuer + subject, so renewal does not break the integration --&gt;
    &lt;identity
       issuer-subject="CN=Contoso Issuing CA 02, O=Contoso, C=GB"
       subject="CN=partner-gateway.acme-logistics.com, O=Acme Logistics" /&gt;
  &lt;/identities&gt;
&lt;/validate-client-certificate&gt;
</code></pre>
<p>The semantics are worth stating precisely, because they're easy to describe wrongly: the certificate must satisfy <strong>every</strong> top-level attribute <em>and</em> match <strong>all</strong> the attributes of <strong>at least one</strong> <code>&lt;identity&gt;</code>. Multiple identities are an OR across partners; attributes within one identity are an AND.</p>
<p><strong>Expression-based equivalent — for logic the policy can't express</strong></p>
<pre><code class="language-xml">&lt;choose&gt;
  &lt;when condition='@(context.Request.Certificate == null
        || !context.Request.Certificate.Verify()
        || context.Request.Certificate.Issuer != "CN=Contoso Issuing CA 02")'&gt;
    &lt;return-response&gt;
      &lt;set-status code="403" reason="Invalid client certificate" /&gt;
    &lt;/return-response&gt;
  &lt;/when&gt;
&lt;/choose&gt;
</code></pre>
<blockquote>
<p><strong>Two operational gotchas</strong></p>
<p><strong>APIM will not receive a certificate unless you ask for one.</strong> On the Consumption tier set <em>Request client certificate</em> in the gateway settings; on other tiers set <code>negotiateClientCertificate</code> on the custom hostname. Without it, <code>context.Request.Certificate</code> is always <code>null</code> and every request 403s while the policy looks correct.</p>
<p><strong>Never pin a thumbprint for a partner integration.</strong> It works until their certificate renews, then it fails outside your change control and inside their maintenance window. Pin issuer plus subject, and if you must pin a thumbprint, pin two so rollover has an overlap.</p>
</blockquote>
<h2>The two-hop gateway pattern</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6aa57fa0ae68813919e6c80d/662f1f54-3107-4c90-a38b-281a480e02fe.png" alt="" style="display:block;margin:0 auto" />

<p>This is the composition worth being able to draw on a whiteboard: the caller's credential is terminated at the gateway, and the gateway uses its <em>own</em> identity to reach the backend. Two independent trust decisions, neither one able to compromise the other.</p>
<p>The partner's credential never reaches the backend. Revoking the partner changes one policy; the backend's trust configuration is untouched and knows only about API Management.</p>
<p><strong>Hop 1 and hop 2 in one policy</strong></p>
<pre><code class="language-xml">&lt;inbound&gt;
  &lt;base /&gt;

  &lt;!-- HOP 1: the caller is authenticated here --&gt;
  &lt;validate-client-certificate validate-trust="true" validate-revocation="true"&gt;
    &lt;identities&gt;
      &lt;identity issuer-subject="CN=Contoso Issuing CA 02, O=Contoso, C=GB"
                subject="CN=partner-gateway.acme-logistics.com" /&gt;
    &lt;/identities&gt;
  &lt;/validate-client-certificate&gt;

  &lt;!-- HOP 2: APIM's managed identity fetches a token for the backend --&gt;
  &lt;authentication-managed-identity
      resource="api://orders-api"
      output-token-variable-name="backend-token"
      ignore-error="false" /&gt;

  &lt;set-header name="Authorization" exists-action="override"&gt;
    &lt;value&gt;@("Bearer " + (string)context.Variables["backend-token"])&lt;/value&gt;
  &lt;/set-header&gt;

  &lt;!-- Carry the caller's identity forward for audit, as a claim not a credential --&gt;
  &lt;set-header name="X-Caller-Subject" exists-action="override"&gt;
    &lt;value&gt;@(context.Request.Certificate?.Subject ?? "unknown")&lt;/value&gt;
  &lt;/set-header&gt;

  &lt;set-backend-service base-url="https://orders-internal.contoso.net" /&gt;
&lt;/inbound&gt;
</code></pre>
<p>The backend accepts exactly one issuer and one audience — itself, from your tenant — so it needs no knowledge of partners, certificates or their renewal cycles.</p>
<blockquote>
<p><strong>Say the caveat yourself</strong></p>
<p><code>X-Caller-Subject</code> is a header the backend must trust only because the network path guarantees it came from APIM — private endpoint, VNet integration, or a backend that validates the gateway's token audience. If the backend is reachable directly, that header is attacker-controlled. Volunteering this limitation is exactly the kind of thing that distinguishes an architect from someone reading a tutorial.</p>
</blockquote>
<h2>If you take one thing</h2>
<p>Check <code>requestedAccessTokenVersion</code> on your API's app registration. If it is <code>null</code> or <code>1</code>, Entra issues tokens whose issuer is <code>https://sts.windows.net/{tenantId}/</code> — not the <code>login.microsoftonline.com/.../v2.0</code> value you configured in your policy. Signature and audience both validate. The issuer does not. The error tells you nothing.</p>
<p>Set it to <code>2</code>, or list both issuers, and you will never lose that afternoon again.</p>
]]></content:encoded></item><item><title><![CDATA[Secretless Azure: Managed Identities and Workload Identity Federation]]></title><description><![CDATA[Part 4 of 6 in the Entra ID Authentication: A Field Guide series.
"We keep the client secret in Key Vault" is a very common answer to a question it does not actually address.
Reading Key Vault needs a]]></description><link>https://shubhankarsarkar.hashnode.dev/secretless-azure-managed-identities-and-workload-identity-federation</link><guid isPermaLink="true">https://shubhankarsarkar.hashnode.dev/secretless-azure-managed-identities-and-workload-identity-federation</guid><category><![CDATA[Azure]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[shubhu1953]]></dc:creator><pubDate>Sat, 12 Sep 2026 17:47:04 GMT</pubDate><content:encoded><![CDATA[<p><em>Part 4 of 6 in the</em> <em><strong>Entra ID Authentication: A Field Guide</strong></em> <em>series.</em></p>
<p>"We keep the client secret in Key Vault" is a very common answer to a question it does not actually address.</p>
<p>Reading Key Vault needs a credential. So either you have another secret to protect, or you use a managed identity for that hop — in which case use it for the resource directly and delete the secret entirely. It is a bootstrap problem, and recognising it is the difference between moving a credential and eliminating one.</p>
<p>This post covers managed identities, workload identity federation for callers outside Azure, and a credential strength ladder you can reason down when asked how two services should authenticate.</p>
<h2>Managed identities: the pattern to default to</h2>
<p>A managed identity is a service principal whose credential Azure provisions, stores in the platform and rotates roughly every 45 days without telling you. Your code never sees it. This is the single highest-leverage security pattern in Azure, and "why would you use a managed identity over a client secret in Key Vault" is an extremely common interview question.</p>
<p>The answer is the bootstrap problem. A secret in Key Vault still needs a credential to <em>read</em> Key Vault. Either you've moved the problem one hop, or you use a managed identity for that hop — in which case you may as well use it for everything.</p>
<table>
<thead>
<tr>
<th></th>
<th>System-assigned</th>
<th>User-assigned</th>
</tr>
</thead>
<tbody><tr>
<td>Lifecycle</td>
<td>Created and deleted with the resource</td>
<td>Standalone Azure resource, outlives any consumer</td>
</tr>
<tr>
<td>Cardinality</td>
<td>Exactly one resource</td>
<td>Many resources share one identity</td>
</tr>
<tr>
<td>Role assignments</td>
<td>Must be re-granted if the resource is recreated</td>
<td>Granted once, survives redeploys</td>
</tr>
<tr>
<td>Use when</td>
<td>Single resource, tight blast radius, identity should die with it</td>
<td>Scale sets, blue/green, many services in one trust domain, pre-provisioning access before the resource exists</td>
</tr>
</tbody></table>
<p>User-assigned identities solve a real deployment ordering problem: you can grant RBAC and Key Vault access <em>before</em> the compute exists, so an infrastructure pipeline doesn't have to run a second privileged pass after deploy. That is usually the deciding factor in IaC-heavy estates, not the security posture — both are equally secretless.</p>
<h3>Getting a token by hand</h3>
<p>SSH onto the VM, or open the Kudu console on an App Service, and ask the local endpoint:</p>
<pre><code class="language-bash"># On an Azure VM — IMDS. Note the mandatory Metadata header.
curl -s -H "Metadata: true" \
  "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&amp;resource=https://vault.azure.net"

# On App Service / Functions / Container Apps — injected env vars instead
curl -s -H "X-IDENTITY-HEADER: $IDENTITY_HEADER" \
  "$IDENTITY_ENDPOINT?api-version=2019-08-01&amp;resource=https://vault.azure.net"
</code></pre>
<pre><code class="language-json">{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs…",
  "expires_on":   "1789016245",
  "resource":     "https://vault.azure.net",
  "token_type":   "Bearer"
}
</code></pre>
<p>No credential was supplied. The platform recognised the caller by <em>where the request came from</em>. That is the whole mechanism — and it is why a managed identity cannot be used from outside Azure.</p>
<h3>In code — never call that URL yourself</h3>
<pre><code class="language-csharp">// Walks a chain of credential sources: managed identity in Azure,
// your az login locally. Same code in both places.
var client = new SecretClient(
    new Uri("https://kv-platform.vault.azure.net/"),
    new DefaultAzureCredential());

KeyVaultSecret secret = await client.GetSecretAsync("orders-db-connection");
</code></pre>
<h2>Workload identity federation</h2>
<p>Managed identities only work for workloads <em>running in Azure</em>. Workload identity federation extends the same secretless model to anything that can present an OIDC token — GitHub Actions, GitLab, Kubernetes service accounts, another cloud.</p>
<p>You add a <strong>federated credential</strong> to an app registration or user-assigned managed identity, declaring the issuer you trust and the exact subject you'll accept. Entra then trades a token from that issuer for one of its own.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aa57fa0ae68813919e6c80d/dd3eb6de-039b-4739-8a01-e4b565a98e8e.png" alt="" style="display:block;margin:0 auto" />

<blockquote>
<p><strong>The subject is the security boundary</strong></p>
<p>A subject of <code>repo:contoso/platform:ref:refs/heads/main</code> is safe. A subject matching <code>repo:contoso/platform:pull_request</code> means anyone who can open a PR against that repo can obtain your production credential. Pin to an environment with required reviewers, or to a protected branch — and say so unprompted in an interview, because that is the trust boundary the whole pattern hangs on.</p>
</blockquote>
<h3>1 · Register the trust, once</h3>
<pre><code class="language-bash">az identity federated-credential create \
  --name github-prod \
  --identity-name mi-deploy \
  --resource-group rg-platform \
  --issuer "https://token.actions.githubusercontent.com" \
  --subject "repo:contoso/platform:environment:production" \
  --audiences "api://AzureADTokenExchange"
</code></pre>
<h3>2 · The workflow</h3>
<pre><code class="language-yaml">permissions:
  id-token: write        # without this, no OIDC token is minted at all
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # must match the subject above
    steps:
      - uses: azure/login@v2
        with:
          client-id:       ${{ vars.AZURE_CLIENT_ID }}
          tenant-id:       ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
          # note: vars, not secrets. None of these are sensitive.

      - run: az webapp deploy -g rg-orders -n app-orders --src-path ./drop.zip
</code></pre>
<p>No <code>AZURE_CREDENTIALS</code> secret, nothing to rotate, nothing to leak. GitHub mints a short-lived OIDC token describing the run; Entra exchanges it only if issuer and subject match what you registered.</p>
<h3>3 · What the subject claim actually looks like</h3>
<pre><code class="language-text">// Safe — the environment gate requires a reviewer
"repo:contoso/platform:environment:production"

// Safe — a protected branch
"repo:contoso/platform:ref:refs/heads/main"

// DANGEROUS — anyone who can open a pull request gets production
"repo:contoso/platform:pull_request"
</code></pre>
<p>The subject string is the entire security boundary. There is no secret to steal, so this one line is what an attacker has to work with — which makes reviewing it the highest-value thing you do in this pattern.</p>
<h2>Choosing a service-to-service pattern</h2>
<table>
<thead>
<tr>
<th>If…</th>
<th>Use</th>
<th>Because</th>
</tr>
</thead>
<tbody><tr>
<td>Caller runs in Azure</td>
<td>Managed identity</td>
<td>No credential exists to leak or rotate</td>
</tr>
<tr>
<td>Downstream authorisation depends on the end user</td>
<td>On-behalf-of</td>
<td>Preserves user context and keeps the audit trail intact</td>
</tr>
<tr>
<td>Service acts autonomously — batch, scheduled, event-driven</td>
<td>Client credentials with an app role</td>
<td>No user exists to delegate from</td>
</tr>
<tr>
<td>Caller is outside Azure but can present OIDC</td>
<td>Workload identity federation</td>
<td>Secretless without running on the platform</td>
</tr>
<tr>
<td>Caller is a partner who can hold a key</td>
<td>Certificate credential</td>
<td>Proof of possession rather than a shared string</td>
</tr>
<tr>
<td>Caller cannot do OAuth at all</td>
<td>mTLS terminated at API Management</td>
<td>Authenticates the channel; pair it with per-request authorisation</td>
</tr>
<tr>
<td>Legacy system can only do a shared secret</td>
<td>Key Vault + automated rotation</td>
<td>Last resort. Make the rotation real, not aspirational.</td>
</tr>
</tbody></table>
<h2>The credential strength ladder</h2>
<p>When you're asked "how should this service authenticate", this is the order to reason down. Take the highest rung the constraints allow, and be able to say why you couldn't take a higher one.</p>
<table>
<thead>
<tr>
<th>Rung</th>
<th>Mechanism</th>
<th>Available when</th>
<th>Residual risk</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Managed identity</td>
<td>Workload runs inside Azure</td>
<td>None to manage — no credential exists to leak</td>
</tr>
<tr>
<td>2</td>
<td>Workload identity federation</td>
<td>Caller is outside but can present an OIDC token</td>
<td>Subject-claim misconfiguration is the whole attack surface</td>
</tr>
<tr>
<td>3</td>
<td>Certificate credential, key in HSM or Key Vault</td>
<td>Caller can hold a key but has no federatable identity</td>
<td>Key compromise; certificate expiry causing outage</td>
</tr>
<tr>
<td>4</td>
<td>mTLS at the gateway</td>
<td>Caller can't do OAuth at all</td>
<td>Authenticates a channel, not a request — pair with per-request authorisation</td>
</tr>
<tr>
<td>5</td>
<td>Client secret with automated rotation</td>
<td>Genuinely nothing above is possible</td>
<td>Bearer material in logs, config and memory</td>
</tr>
<tr>
<td>6</td>
<td>Static API key</td>
<td>Never, for service-to-service</td>
<td>No expiry, no identity, no revocation granularity</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>How to deploy this in an interview</strong></p>
<p>When asked how two services should authenticate, don't lead with a mechanism. Ask one question — <em>"is the caller running inside Azure?"</em> — and then walk the ladder out loud. It demonstrates a decision procedure rather than a memorised answer, and it gives the panel somewhere to push, which is what they're trying to do.</p>
</blockquote>
<h2>Anti-patterns</h2>
<table>
<thead>
<tr>
<th>Anti-pattern</th>
<th>Why it fails</th>
<th>Do instead</th>
</tr>
</thead>
<tbody><tr>
<td>Client secrets in pipelines</td>
<td>Rotation is manual and therefore doesn't happen</td>
<td>Workload identity federation</td>
</tr>
<tr>
<td>Secret in Key Vault, read using another secret</td>
<td>Moves the bootstrap problem one hop without solving it</td>
<td>Managed identity to reach Key Vault, or skip the vault entirely</td>
</tr>
<tr>
<td>Application permissions where delegated would do</td>
<td>Removes the user's own permission ceiling</td>
<td>Delegated, or application permission plus a scoping policy</td>
</tr>
<tr>
<td>Validating a token's signature but not <code>aud</code></td>
<td>A token minted for another application passes</td>
<td>Validate issuer and audience on every request</td>
</tr>
<tr>
<td>Keying user records off email or UPN</td>
<td>Both are mutable; people get renamed and remarried</td>
<td><code>oid</code> plus <code>tid</code></td>
</tr>
<tr>
<td>ROPC to support a legacy client</td>
<td>Your app handles the password, and MFA and Conditional Access silently stop applying</td>
<td>Device code flow, or an authentication broker</td>
</tr>
<tr>
<td>One app registration shared across partners</td>
<td>You cannot revoke or attribute per partner</td>
<td>One registration each</td>
</tr>
<tr>
<td>Federated credential pinned to a repo, not an environment</td>
<td>Anyone who can open a pull request gets the production identity</td>
<td>Pin <code>sub</code> to a protected environment with required reviewers</td>
</tr>
</tbody></table>
<h2>Where to land</h2>
<p>Inside Azure, it is a managed identity and there is no credential to steal. Outside, but able to present an OIDC token, it is workload identity federation. Only below those does a certificate become the right answer, and only below that a secret.</p>
<p>If you take one thing from this: "the secret is in Key Vault" is not an answer to "how does this service authenticate". It is the same question, one hop further down.</p>
]]></content:encoded></item><item><title><![CDATA[OAuth 2.0 Grant Types in Azure: Which to Use, and Which Are Dead]]></title><description><![CDATA[Part 3 of 6 in the Entra ID Authentication: A Field Guide series.
Five OAuth flows matter in Azure and two are actively harmful. Which ones you reach for says a lot about whether you learned OAuth in ]]></description><link>https://shubhankarsarkar.hashnode.dev/oauth-2-0-grant-types-in-azure-which-to-use-and-which-are-dead</link><guid isPermaLink="true">https://shubhankarsarkar.hashnode.dev/oauth-2-0-grant-types-in-azure-which-to-use-and-which-are-dead</guid><category><![CDATA[OAuth2]]></category><category><![CDATA[Azure]]></category><category><![CDATA[Security]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[shubhu1953]]></dc:creator><pubDate>Sat, 12 Sep 2026 17:42:42 GMT</pubDate><content:encoded><![CDATA[<p><em>Part 3 of 6 in the</em> <em><strong>Entra ID Authentication: A Field Guide</strong></em> <em>series.</em></p>
<p>Five OAuth flows matter in Azure and two are actively harmful. Which ones you reach for says a lot about whether you learned OAuth in 2015 and stopped.</p>
<p>This post walks the live flows as raw HTTP so you can see what actually differs, explains why PKCE protects a public client that has no secret to protect it, and covers on-behalf-of — the Entra-specific flow for when your API must call a downstream API <em>as the original user</em>.</p>
<h2>OAuth 2.0 grant types, and which are dead</h2>
<p>Five flows matter and two are actively harmful. Interviewers ask this because it separates people who learned OAuth in 2015 from people who kept up.</p>
<table>
<thead>
<tr>
<th>Grant</th>
<th>Use when</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td>Authorization code + PKCE</td>
<td>Any app with a user present — SPA, mobile, server-rendered web</td>
<td>The default. Use this.</td>
</tr>
<tr>
<td>Client credentials</td>
<td>Daemon, background job, service-to-service with no user</td>
<td>Correct for machine identity</td>
</tr>
<tr>
<td>On-behalf-of</td>
<td>API must call a downstream API <em>as the original user</em></td>
<td>Entra-specific, correct</td>
</tr>
<tr>
<td>Device code</td>
<td>Input-constrained device — TV, CLI on a headless box</td>
<td>Legitimate, narrow</td>
</tr>
<tr>
<td>Implicit</td>
<td>—</td>
<td>Deprecated. Tokens in the URL fragment leak via history and referrer.</td>
</tr>
<tr>
<td>Resource owner password (ROPC)</td>
<td>—</td>
<td>Avoid. Your app handles the password, and it silently breaks MFA and Conditional Access.</td>
</tr>
</tbody></table>
<p>The reason implicit died is worth being able to explain: it existed because browsers couldn't do cross-origin POST to the token endpoint before CORS was widely supported. CORS solved that, so a SPA can now do a proper code exchange. PKCE then closes the remaining gap — a public client can't keep a secret, so the authorization code alone is stealable.</p>
<h2>How PKCE actually protects a public client</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6aa57fa0ae68813919e6c80d/f6b2c1b6-47f1-46c7-b0ec-6efe7e76a427.png" alt="" style="display:block;margin:0 auto" />

<p>PKCE — Proof Key for Code Exchange — solves one specific problem: the authorization code travels back through the browser, where it can be intercepted, and a public client has no secret with which to prove it should be the one redeeming it.</p>
<p>There is <strong>no certificate and no key pair in PKCE</strong>, and nothing is registered in advance. The client invents a secret itself, fresh for every sign-in attempt. That is what makes it work for apps that cannot hold a credential at all.</p>
<h3>Step 1 · the client invents a secret</h3>
<p>High-entropy random string, 43–128 characters. Generated in memory, registered with nobody, discarded seconds later.</p>
<pre><code class="language-text">code_verifier  = j4ANsyTHtPZktY38c1Q2x8J4DV-KpD_VeIzI501OCL2QZDpT9Wx0_Q
</code></pre>
<h3>Step 2 · it hashes it</h3>
<pre><code class="language-text">code_challenge = BASE64URL( SHA256( code_verifier ) )
               = 1XisoZOMe5ysmE-v8xegx-oFJqIc26maivy_s7QWxww
</code></pre>
<p>Reproduce it yourself — the values above are real:</p>
<pre><code class="language-bash">python -c "import hashlib,base64; v=b'j4ANsyTHtPZktY38c1Q2x8J4DV-KpD_VeIzI501OCL2QZDpT9Wx0_Q'; \
print(base64.urlsafe_b64encode(hashlib.sha256(v).digest()).rstrip(b'=').decode())"
</code></pre>
<h3>Step 3 · the hash crosses the dangerous channel</h3>
<p>The <code>/authorize</code> call travels through the browser — address bar, history, redirects, possibly a malicious app registered for the same URI scheme. So only the <em>hash</em> goes:</p>
<pre><code class="language-text">GET /authorize?…&amp;code_challenge=1XisoZOMe5ysmE-v8xegx-oFJqIc26maivy_s7QWxww&amp;code_challenge_method=S256
</code></pre>
<p>Entra writes that challenge down next to the authorization code it is about to issue. It validates nothing yet — it has nothing to compare against. This step is pure storage.</p>
<h3>Step 4 · the verifier crosses the safe channel</h3>
<p>The <code>/token</code> call is a direct back-channel HTTPS POST. No browser, no redirect, no intermediary. <em>Now</em> the original is sent:</p>
<pre><code class="language-text">POST /token
grant_type=authorization_code&amp;code=0.AXoAv4j5…&amp;code_verifier=j4ANsyTHtPZktY38c1Q2x8J4DV-KpD_VeIzI501OCL2QZDpT9Wx0_Q
</code></pre>
<h3>Step 5 · the server checks</h3>
<p>Entra computes <code>SHA256</code> of the verifier it just received and compares it to the challenge stored in step 3. Match, and it issues tokens. No match, or no verifier at all, and the code is rejected and burned.</p>
<blockquote>
<p><strong>Why interception fails</strong></p>
<p>The attacker has the code, stolen from the redirect. To redeem it they need the verifier. What they have instead is the challenge — a SHA-256 hash — and reversing that means inverting SHA-256.</p>
<p>The asymmetry is deliberate: <strong>the hash crosses the interceptable channel, the preimage crosses the direct one.</strong> Verification costs one hash operation; forgery requires breaking the hash function.</p>
</blockquote>
<h3>Three different cryptographic mechanisms, one flow</h3>
<p>This is where PKCE is most often misunderstood. There are three separate things happening in an OAuth exchange, and they answer three different questions. Only one of them involves a certificate.</p>
<table>
<thead>
<tr>
<th>Mechanism</th>
<th>Question it answers</th>
<th>Credential</th>
<th>Lifetime</th>
<th>Registered in advance?</th>
</tr>
</thead>
<tbody><tr>
<td><strong>PKCE</strong></td>
<td>Is this the same app that <em>started</em> this flow?</td>
<td>Self-generated random string</td>
<td>One request, seconds</td>
<td>No — nothing pre-shared</td>
</tr>
<tr>
<td><strong>Client secret or certificate</strong></td>
<td>Is this app <em>who it claims to be</em>?</td>
<td>Shared string, or a private key</td>
<td>Months to years</td>
<td>Yes — you register it</td>
</tr>
<tr>
<td><strong>Token signature + JWKS</strong></td>
<td>Did <em>Entra</em> really mint this token?</td>
<td>Entra's own key pair</td>
<td>Rotated by Microsoft</td>
<td>Entra publishes the public half</td>
</tr>
</tbody></table>
<p><strong>PKCE does not authenticate the client.</strong> It only binds the authorization code to whoever began the flow. A public client — a SPA, a mobile app — remains fundamentally unauthenticated afterwards, because anything shipped to a user's device can be read by that user.</p>
<p>Which is why the two compose rather than compete: a confidential client uses <strong>PKCE <em>and</em> a secret or certificate</strong>. PKCE stops code interception; the credential proves app identity. It also explains why PKCE never appears in the client credentials flow — no browser, no redirect, no authorization code, so nothing to intercept and nothing to bind.</p>
<h2>The on-behalf-of flow</h2>
<p>A user calls your API, and your API must call Microsoft Graph <em>as that user</em>. You cannot reuse the incoming token — its <code>aud</code> is your API, not Graph. On-behalf-of exchanges one for the other while preserving user context.</p>
<p>Your API received token A. It needs token B for Graph. This is the exchange:</p>
<pre><code class="language-text">POST /72f988bf-…/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&amp;client_id=a1b2c3d4-5e6f-4711-8a9b-0c1d2e3f4a5b
&amp;client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&amp;client_assertion=eyJhbGciOiJQUzI1NiIs…
&amp;assertion=eyJ0eXAiOiJKV1QiLCJhbGci…
&amp;scope=https://graph.microsoft.com/Mail.Read
&amp;requested_token_use=on_behalf_of
</code></pre>
<p>Two credentials in one request. Your API authenticates <em>itself</em> with the <code>client_assertion</code>, and passes the <em>user's</em> token as <code>assertion</code>. Entra returns a token for Graph that still carries the user's <code>oid</code>.</p>
<p>In MSAL it is one call:</p>
<pre><code class="language-csharp">// incomingToken is the raw bearer string your API received
var result = await app
    .AcquireTokenOnBehalfOf(
        new[] { "https://graph.microsoft.com/Mail.Read" },
        new UserAssertion(incomingToken))
    .ExecuteAsync();
</code></pre>
<h3>What the alternative would cost you</h3>
<p>If your API used client credentials instead, Graph would apply the API's tenant-wide <code>Mail.Read</code> application permission — every mailbox in the tenant — and the audit log would record your API rather than the person who asked. Reach for on-behalf-of whenever the downstream authorisation decision should depend on <em>who asked</em>.</p>
<h2>Rules of thumb</h2>
<p>A user is present: authorization code with PKCE. No user: client credentials with an app role. Your API needs to call something else on the user's behalf: on-behalf-of, not client credentials, or you lose both the permission ceiling and the audit trail.</p>
<p>And the tell for reading someone else's code: <code>.default</code> and <code>roles</code> mean machine. Named scopes and <code>scp</code> mean user.</p>
]]></content:encoded></item><item><title><![CDATA[The One Line of Token Validation Everyone Skips]]></title><description><![CDATA[Part 2 of 6 in the Azure Authentication, Properly series.
Here is a bug I have seen in production more than once. An API validates the signature on every incoming JWT, against the correct tenant, usin]]></description><link>https://shubhankarsarkar.hashnode.dev/the-one-line-of-token-validation-everyone-skips</link><guid isPermaLink="true">https://shubhankarsarkar.hashnode.dev/the-one-line-of-token-validation-everyone-skips</guid><dc:creator><![CDATA[shubhu1953]]></dc:creator><pubDate>Sat, 12 Sep 2026 17:16:45 GMT</pubDate><content:encoded><![CDATA[<p><em>Part 2 of 6 in the</em> <em><strong>Azure Authentication, Properly</strong></em> <em>series.</em></p>
<p>Here is a bug I have seen in production more than once. An API validates the signature on every incoming JWT, against the correct tenant, using a properly cached JWKS. It looks rigorous. It is wide open.</p>
<p>Signature validation proves the token was not forged. It says nothing about who the token was <em>for</em>. Miss the <code>aud</code> check and any token your tenant issued — including one a user obtained for a completely different application — sails straight through.</p>
<p>This post covers what is actually inside an Entra token, the claims that carry the authorisation decision, and the two distinctions that cause most of the confusion: app registration versus service principal, and delegated versus application permissions.</p>
<h2>Tokens and claims</h2>
<p>Three words get used interchangeably and shouldn't be. <strong>Authentication</strong> establishes who a principal is. <strong>Authorisation</strong> decides what that principal may do. <strong>Identity federation</strong> is the act of one system trusting another system's authentication, so the principal doesn't need a second set of credentials.</p>
<p>Modern cloud auth runs on three protocol families, and knowing which does what is table stakes:</p>
<table>
<thead>
<tr>
<th>Protocol</th>
<th>What it actually does</th>
<th>Token format</th>
<th>Where you meet it</th>
</tr>
</thead>
<tbody><tr>
<td>OAuth 2.0</td>
<td><em>Authorisation.</em> Delegates access to a resource without sharing the user's password. Says nothing about who the user is.</td>
<td>Opaque or JWT access token</td>
<td>Every API call in Azure</td>
</tr>
<tr>
<td>OpenID Connect</td>
<td><em>Authentication.</em> A thin identity layer on top of OAuth 2.0. Adds the ID token and a discovery document.</td>
<td>JWT ID token</td>
<td>Entra sign-in, GitHub Actions OIDC</td>
</tr>
<tr>
<td>SAML 2.0</td>
<td><em>Authentication</em> via XML assertions and browser POST. Older, verbose, still everywhere in enterprise SSO.</td>
<td>Signed XML assertion</td>
<td>AD FS, legacy SaaS, enterprise SSO</td>
</tr>
</tbody></table>
<p>These are the claims that matter:</p>
<table>
<thead>
<tr>
<th>Claim</th>
<th>Meaning</th>
<th>Why an architect cares</th>
</tr>
</thead>
<tbody><tr>
<td><code>iss</code></td>
<td>Issuer — who minted the token</td>
<td>Must match your expected authority exactly. A token from the wrong tenant is a valid JWT.</td>
</tr>
<tr>
<td><code>aud</code></td>
<td>Audience — who the token is <em>for</em></td>
<td>The single most-skipped validation. A token issued for Graph is not valid for your API.</td>
</tr>
<tr>
<td><code>sub</code></td>
<td>Subject — the principal, scoped to the issuer</td>
<td>Pairwise in Entra: the same user has a different <code>sub</code> per application.</td>
</tr>
<tr>
<td><code>exp</code> / <code>nbf</code></td>
<td>Expiry / not-before</td>
<td>Allow small clock skew (60s), never a large one.</td>
</tr>
<tr>
<td><code>scp</code></td>
<td>Delegated scopes</td>
<td>Present when acting <em>on behalf of a user</em>.</td>
</tr>
<tr>
<td><code>roles</code></td>
<td>App roles / application permissions</td>
<td>Present when the app acts <em>as itself</em>. Mutually exclusive with <code>scp</code> in practice.</td>
</tr>
<tr>
<td><code>oid</code> / <code>tid</code></td>
<td>Object ID / tenant ID</td>
<td>The only stable user identifier. Never key off email or UPN — both are mutable.</td>
</tr>
<tr>
<td><code>azp</code></td>
<td>Authorised party — the calling client</td>
<td>Lets you attribute a call to a specific partner application.</td>
</tr>
<tr>
<td><code>amr</code></td>
<td>Authentication methods used</td>
<td>How you prove MFA actually happened rather than assuming it.</td>
</tr>
</tbody></table>
<h2>The identity objects, and why there are so many</h2>
<table>
<thead>
<tr>
<th>Object</th>
<th>What it is</th>
<th>Lifecycle</th>
</tr>
</thead>
<tbody><tr>
<td>User</td>
<td>A human. Cloud-only, or synced from on-prem AD via Entra Connect.</td>
<td>Managed in the directory or by HR-driven provisioning</td>
</tr>
<tr>
<td>Group</td>
<td>Security or Microsoft 365 group. Assigned or dynamic (rule-based membership).</td>
<td>The unit you should assign access to, not users</td>
</tr>
<tr>
<td>App registration</td>
<td>The global <em>definition</em> of an application — its redirect URIs, exposed scopes, app roles.</td>
<td>Lives in the tenant that owns the app</td>
</tr>
<tr>
<td>Service principal</td>
<td>The <em>local instance</em> of that application in a tenant. What permissions actually attach to.</td>
<td>One per tenant that consents to the app</td>
</tr>
<tr>
<td>Managed identity</td>
<td>A service principal whose credential Azure creates, stores and rotates for you.</td>
<td>System-assigned dies with the resource; user-assigned is standalone</td>
</tr>
<tr>
<td>Federated credential</td>
<td>A trust relationship on an app or managed identity pointing at an external OIDC issuer.</td>
<td>No secret at all — the whole point</td>
</tr>
</tbody></table>
<p>The app registration / service principal split is the distinction that confuses everyone. Ask Entra for both and you get two different ids:</p>
<pre><code class="language-bash"># The application object — the global definition
az ad app show --id api://orders-api \
  --query "{appId:appId, objectId:id, uris:identifierUris}"

{
  "appId":    "a1b2c3d4-5e6f-4711-8a9b-0c1d2e3f4a5b",   // same in every tenant
  "objectId": "9e7c4a18-3b62-4f09-85d1-6c2a7e0b4d53",   // the app registration
  "uris":     ["api://orders-api"]
}

# The service principal — the local instance that permissions attach to
az ad sp show --id a1b2c3d4-5e6f-4711-8a9b-0c1d2e3f4a5b \
  --query "{appId:appId, objectId:id}"

{
  "appId":    "a1b2c3d4-5e6f-4711-8a9b-0c1d2e3f4a5b",   // unchanged
  "objectId": "4d8f1e27-9a03-4c65-b7e2-1f5c8d3a6b90"    // DIFFERENT — the SP
}
</code></pre>
<p>Same <code>appId</code>, two different object ids. Role assignments, app role grants and sign-in logs all reference the <strong>service principal</strong> id, not the app registration id. Passing the wrong one is the cause of most "resource not found" errors when scripting Graph.</p>
<p>Think of the registration as a class and the service principal as an instance. In a single-tenant line-of-business app you'll have one of each and never notice. In a multi-tenant SaaS app, you have one registration in your home tenant and a service principal in every customer tenant that consented — and permissions, Conditional Access and sign-in logs all attach to the service principal, not the registration.</p>
<h2>Delegated versus application permissions</h2>
<p>This is the distinction Entra interviews live on.</p>
<h3>Delegated (<code>scp</code>)</h3>
<p>The app acts <strong>on behalf of a signed-in user</strong>. There is always a user in the picture.</p>
<p><strong>Effective permission = intersection</strong> of what the app was granted and what the user is actually allowed to do. An app with <code>Files.ReadWrite.All</code> delegated still can't touch a file the user can't reach.</p>
<p>Consent can come from the user, or require an admin depending on the scope's sensitivity.</p>
<h3>Application (<code>roles</code>)</h3>
<p>The app acts <strong>as itself</strong>. No user, no user context, no intersection.</p>
<p><strong>Effective permission = exactly what was granted</strong>, tenant-wide. <code>Files.ReadWrite.All</code> here means every file in the tenant.</p>
<p>Always requires admin consent. This is where over-privileged daemons quietly become the largest risk in an estate.</p>
<blockquote>
<p><strong>Where this goes wrong</strong></p>
<p>A background job needs to read one mailbox. The quick fix is <code>Mail.Read</code> as an <em>application</em> permission, which grants read on every mailbox in the tenant. The correct fix is application permission plus an <strong>application access policy</strong> scoping it to a named mailbox or group. If you can articulate this, you're demonstrating that you think about blast radius rather than getting the call to work.</p>
</blockquote>
<p>Both of the tokens below are valid for <code>api://orders-api</code>. Your validation logic must treat them differently.</p>
<h3>Delegated — a user is behind the call</h3>
<pre><code class="language-json">{
  "aud": "api://orders-api",
  "azp": "a1b2c3d4-5e6f-4711-8a9b-0c1d2e3f4a5b",
  "azpacr": "0",
  "name": "Priya Raman",
  "oid": "3c9e1b74-2a5f-4d86-b0c7-9e4f1a2d8b63",
  "preferred_username": "priya.raman@contoso.com",
  "scp": "Orders.Read Orders.Write",
  "sub": "k9Hx2vQpL7nR4tYw",
  "tid": "72f988bf-86f1-41af-91ab-2d7cd011db47"
}
</code></pre>
<p><code>scp</code> is a <strong>space-delimited string</strong>. There is a user: <code>name</code>, <code>preferred_username</code> and a user <code>oid</code>. Effective permission is the intersection of these scopes and what Priya herself can reach.</p>
<h3>Application — the app acts as itself</h3>
<pre><code class="language-json">{
  "aud": "api://orders-api",
  "azp": "a1b2c3d4-5e6f-4711-8a9b-0c1d2e3f4a5b",
  "azpacr": "2",
  "roles": ["Orders.Read"],
  "oid": "7f3d9c21-64b8-4e0a-9d31-5a8e2b7c1f04",
  "sub": "7f3d9c21-64b8-4e0a-9d31-5a8e2b7c1f04",
  "tid": "72f988bf-86f1-41af-91ab-2d7cd011db47"
}
</code></pre>
<p><code>roles</code> is an <strong>array</strong>. No <code>name</code>, no <code>preferred_username</code>, and <code>oid</code> equals <code>sub</code> — both are the service principal, because there is no user. Effective permission is exactly <code>Orders.Read</code>, tenant-wide.</p>
<table>
<thead>
<tr>
<th></th>
<th>Delegated</th>
<th>Application</th>
</tr>
</thead>
<tbody><tr>
<td>Permission claim</td>
<td><code>scp</code> — space-delimited string</td>
<td><code>roles</code> — array</td>
</tr>
<tr>
<td>User claims present</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td><code>oid</code> identifies</td>
<td>The user</td>
<td>The service principal</td>
</tr>
<tr>
<td><code>azpacr</code></td>
<td><code>0</code> — public client</td>
<td><code>1</code> secret · <code>2</code> certificate</td>
</tr>
<tr>
<td>Ceiling</td>
<td>Intersection with the user's own rights</td>
<td>Exactly what was consented</td>
</tr>
</tbody></table>
<p><strong>Practical consequence:</strong> code that reads <code>scp</code> will silently authorise nothing when a daemon calls, and code that reads <code>roles</code> will silently authorise nothing when a user calls. Handle both explicitly, or reject the shape you do not expect.</p>
<h2>Conditional Access as the policy decision point</h2>
<p>Conditional Access is where Entra stops being a directory and becomes a policy engine. Every sign-in is evaluated against policies shaped as <em>signals → decision → session control</em>.</p>
<ul>
<li><p><strong>Signals</strong> — user or group, target application, device state (compliant, hybrid-joined), client app type, location or named network, real-time sign-in risk and user risk from Identity Protection.</p>
</li>
<li><p><strong>Decisions</strong> — block, or grant subject to MFA, compliant device, approved client app, app protection policy, terms of use.</p>
</li>
<li><p><strong>Session controls</strong> — sign-in frequency, persistent browser, app-enforced restrictions, Defender for Cloud Apps proxying, token protection.</p>
</li>
</ul>
<p>Two things to be able to say about it. First, CA policies are <strong>evaluated after first-factor authentication</strong> — they gate token issuance, not credential verification, which is why they can't protect legacy protocols that don't do modern auth. Second, <strong>block always wins</strong> over grant, and a policy with no assignment exclusions will eventually lock you out, which is exactly what break-glass accounts exist for.</p>
<h2>Hybrid identity</h2>
<p>Most enterprises still have Active Directory, and how you bridge it to Entra is a standard design question.</p>
<ul>
<li><p><strong>Password hash sync</strong> — a hash of the password hash syncs to Entra. Simplest, most resilient, and cloud sign-in keeps working when the data centre doesn't. The default recommendation.</p>
</li>
<li><p><strong>Pass-through authentication</strong> — validation happens on-premises via lightweight agents. No password material in the cloud, but cloud sign-in now depends on on-premises availability.</p>
</li>
<li><p><strong>Federation with AD FS</strong> — full control and support for exotic requirements, at the cost of running highly-available infrastructure on the critical path of every sign-in. Being migrated away from almost everywhere.</p>
</li>
</ul>
<p>The architect's point is that the first two keep authentication working when on-premises infrastructure fails, and that availability is usually worth more than the control federation buys.</p>
<h2>What to take away</h2>
<p>Validate the signature, then <code>iss</code>, then <code>aud</code>, on every request. Point your validator at the OIDC discovery document rather than pinning a key, so Microsoft's rotation is invisible to you. And key your user records off <code>oid</code> plus <code>tid</code> — never email or UPN, both of which change when people get married or renamed.</p>
<p>If you only change one thing after reading this: check that your API rejects a token whose <code>aud</code> is some other application in your own tenant. A surprising number do not.</p>
]]></content:encoded></item><item><title><![CDATA[How a Service Proves Its Identity to Microsoft Entra ID]]></title><description><![CDATA[Part 1 of 6 in the Azure Authentication, Properly series.
If the client generates its own credential, what stops anyone else doing the same?
That is the question certificate authentication always come]]></description><link>https://shubhankarsarkar.hashnode.dev/how-a-service-proves-its-identity-to-microsoft-entra-id</link><guid isPermaLink="true">https://shubhankarsarkar.hashnode.dev/how-a-service-proves-its-identity-to-microsoft-entra-id</guid><dc:creator><![CDATA[shubhu1953]]></dc:creator><pubDate>Sat, 12 Sep 2026 17:01:55 GMT</pubDate><content:encoded><![CDATA[<p><em>Part 1 of 6 in the</em> <em><strong>Azure Authentication, Properly</strong></em> <em>series.</em></p>
<p>If the client generates its own credential, what stops anyone else doing the same?</p>
<p>That is the question certificate authentication always comes down to, and most explanations skip straight past it into <code>openssl</code> flags. This post answers it directly, with a diagram of what happens once at setup, a diagram of what happens on every call, and a short sequence of commands you can run yourself to watch a forgery attempt fail.</p>
<p>It is written from the position of the API owner — you own the API, a partner service needs to call it, there is no user involved, and they will authenticate with a certificate.</p>
<h2>How a confidential client proves who it is</h2>
<p>PKCE leaves a gap. It binds the authorization code to whoever started the flow, but it says nothing about <em>which application</em> is calling — and for service-to-service traffic there is no user, no browser and no authorization code at all. Something else has to establish identity.</p>
<p>That something is a credential the app can prove it holds. The strongest form available to a caller outside Azure is a certificate, and it raises the question that certificate authentication always comes down to: <strong>if the client generates its own credential, what stops anyone else doing the same?</strong></p>
<blockquote>
<p><strong>The answer in three sentences</strong></p>
<p>The client generates a <strong>key pair</strong> — two mathematically linked files. One can create signatures; the other can only check them, never create them.</p>
<p>The client keeps the creating half forever. You receive the checking half and upload it to Entra in advance. Later the client signs something, and Entra verifies it using the half you gave it — <strong>without ever having seen the private key</strong>.</p>
<p>Anyone can build the same message. Only the holder of the private key can produce a signature that verifies against the certificate on your app registration.</p>
</blockquote>
<h3>Phase 1 · Setup — happens once, before anything runs</h3>
<p><em>Nothing secret moves in either direction. The partner creates both halves; only the verifying half travels. Entra records it as usage: "Verify" — the platform itself stating this key can check signatures but never produce them.</em></p>
<blockquote>
<p><strong>The thing people get backwards</strong></p>
<p>You do <strong>not</strong> generate the pair and send the partner a private key. If you did, two parties would hold the signing key, and a fraudulent call in your logs could no longer be attributed to either of them. That destroys the one property you adopted certificates for — and leaves you with a client secret that is harder to rotate.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/6aa57fa0ae68813919e6c80d/a43347cc-1cb6-4be2-9b42-55ece62168ae.png" alt="" style="display:block;margin:0 auto" />

<h3>Phase 2 · Runtime — happens on every token request</h3>
<p><em>Step 2 is the only step an impostor cannot perform. Everything else — writing the JSON, knowing the client_id, assembling the JWT, posting it — is public knowledge. The signature is the whole of the security, and producing one requires the private key that never left the partner's estate. Steps 1–3 involve no network call at all: the assertion is not issued by anyone, it is manufactured locally.</em></p>
<img src="https://cdn.hashnode.com/uploads/covers/6aa57fa0ae68813919e6c80d/a79bd188-60c4-4444-aca3-8094be6bdc7f.png" alt="" style="display:block;margin:0 auto" />

<h3>Why an impostor fails, concretely</h3>
<p>Suppose an attacker finds the <code>client_id</code> in a config file and tries the same request.</p>
<table>
<thead>
<tr>
<th>They can…</th>
<th>But…</th>
</tr>
</thead>
<tbody><tr>
<td>Write an identical JSON payload</td>
<td>It is not secret. This gets them nothing on its own.</td>
</tr>
<tr>
<td>Use the real <code>client_id</code></td>
<td>It is a public identifier, like a username.</td>
</tr>
<tr>
<td>Sign it with <em>their own</em> key</td>
<td>Entra verifies against the certificate <em>you</em> uploaded. Different key, no match.</td>
</tr>
<tr>
<td>Replay a captured assertion</td>
<td><code>jti</code> is single-use and <code>exp</code> is around five minutes.</td>
</tr>
<tr>
<td>Alter a captured assertion's claims</td>
<td>The signature covers the exact bytes. One changed character invalidates it.</td>
</tr>
<tr>
<td>Derive the private key from the certificate</td>
<td>That is the factoring problem. Not feasible.</td>
</tr>
</tbody></table>
<h3>What each party ends up holding</h3>
<table>
<thead>
<tr>
<th>Party</th>
<th>Holds</th>
<th>Can they impersonate the partner?</th>
</tr>
</thead>
<tbody><tr>
<td>The partner</td>
<td><code>partner.key</code> — private</td>
<td>Yes — they <em>are</em> the partner</td>
</tr>
<tr>
<td>You</td>
<td><code>partner.cer</code> — public</td>
<td>No</td>
</tr>
<tr>
<td>Entra / Microsoft</td>
<td><code>partner.cer</code> — public</td>
<td>No</td>
</tr>
<tr>
<td>Anyone reading the email</td>
<td><code>partner.cer</code> — public</td>
<td>No</td>
</tr>
</tbody></table>
<p>Contrast a client secret, where that last column would read <em>yes</em> on every row. That is the entire reason to prefer a certificate.</p>
<h3>The handover, in both directions</h3>
<table>
<thead>
<tr>
<th>Direction</th>
<th>What moves</th>
<th>Sensitive?</th>
</tr>
</thead>
<tbody><tr>
<td>Partner → you</td>
<td><code>partner.cer</code></td>
<td>No — safe in plain email</td>
</tr>
<tr>
<td>You → partner</td>
<td><code>tenantId</code>, <code>clientId</code>, <code>api://orders-api/.default</code>, API base URL</td>
<td>No — safe in plain email</td>
</tr>
<tr>
<td>Either direction</td>
<td>The private key</td>
<td>It never moves. There is nothing to send.</td>
</tr>
</tbody></table>
<p>If your onboarding process ever requires you to <em>securely transmit</em> something to the partner, you have drifted back to shared-secret territory and lost the benefit. The assertion is taken apart claim by claim further down this post, and post 6 in this series has the <code>az</code> commands for registering all of it.</p>
<h2>Why a certificate beats a client secret</h2>
<p>A client secret is bearer material. Whoever holds the string <em>is</em> the application — there is no way for Entra to tell the legitimate service from someone who found the secret in a config file, a pipeline log, or an exception dump. A certificate credential changes the proof from "I know a string" to "I can sign with a key I hold".</p>
<table>
<thead>
<tr>
<th></th>
<th>Client secret</th>
<th>Certificate credential</th>
</tr>
</thead>
<tbody><tr>
<td>What travels on the wire</td>
<td>The secret itself, on every token request</td>
<td>A signature. The private key never leaves the holder.</td>
</tr>
<tr>
<td>If intercepted or logged</td>
<td>Full compromise of the app identity</td>
<td>The assertion is single-use, short-lived and audience-bound</td>
</tr>
<tr>
<td>Maximum lifetime in Entra</td>
<td>24 months</td>
<td>Bounded by the certificate, and rotatable without downtime</td>
</tr>
<tr>
<td>Storage</td>
<td>Key Vault secret — still a string someone can read</td>
<td>Key Vault certificate, or an HSM-backed key that cannot be exported</td>
</tr>
<tr>
<td>Rotation</td>
<td>Manual coordination; an expired secret is an outage</td>
<td>Subject name / issuer auth lets you roll certificates transparently</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>Where this genuinely applies</strong></p>
<p>Certificate credentials are the right answer when the calling workload <strong>cannot</strong> use a managed identity — a service running on-premises, in another cloud, or in a third party's estate that needs to call your Entra-protected API. Inside Azure, a managed identity is still better, because it removes the credential entirely rather than improving it. Say that distinction out loud; it shows you're picking the pattern for the constraint rather than reciting the strongest-sounding option.</p>
</blockquote>
<h2>The client assertion, claim by claim</h2>
<p>Certificate-based client credentials work by <code>private_key_jwt</code>: the client builds a short-lived JWT, signs it with the certificate's private key, and presents that instead of a secret. Entra verifies the signature against the public key registered on the app.</p>
<p><strong>Assertion header — identifies which registered certificate signed this</strong></p>
<pre><code class="language-json">{
  "alg": "PS256",
  "typ": "JWT",
  "x5t#S256": "kg3W1Hh9Nn_Yp2QqR0sT..."
}
</code></pre>
<p><strong>Assertion payload</strong></p>
<pre><code class="language-json">{
  "aud": "https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token",
  "iss": "{clientId}",
  "sub": "{clientId}",
  "jti": "6f1b...-unique",
  "iat": 1789012345,
  "nbf": 1789012345,
  "exp": 1789012645
}
</code></pre>
<p>The <code>jti</code> is a single-use id that blocks replay, and <code>exp</code> should be five to ten minutes at most.</p>
<p><strong>Token request</strong></p>
<pre><code class="language-bash">POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&amp;client_id={clientId}
&amp;scope=api%3A%2F%2Forders-api%2F.default
&amp;client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&amp;client_assertion={the signed JWT above}
</code></pre>
<blockquote>
<p><strong>Three details that trip people up</strong></p>
<p><strong>The</strong> <code>aud</code> <strong>is the token endpoint</strong>, not the tenant's v2.0 issuer URL. Getting this wrong yields an unhelpful <code>AADSTS700027</code>.</p>
<p><code>.default</code> <strong>is not a scope you invented.</strong> In the client credentials flow you cannot request individual scopes — you request <code>{resource}/.default</code> and receive every application permission already consented for that resource. Granularity comes from what an admin consented to, not from the request.</p>
<p><strong>Use</strong> <code>x5t#S256</code> <strong>with</strong> <code>PS256</code> for new work. The older <code>x5t</code> header carried a SHA-1 thumbprint with <code>RS256</code>; it still works, but SHA-1 is not a thing to put in a design document in 2026.</p>
</blockquote>
<h2>Doing it in .NET — and not hand-rolling it</h2>
<p>You almost never build that assertion yourself. MSAL constructs and signs it for you:</p>
<pre><code class="language-csharp">// Pull the cert from Key Vault using the host's managed identity.
// Nothing secret is ever in appsettings or an environment variable.
var certClient = new CertificateClient(
    new Uri("https://kv-platform.vault.azure.net/"),
    new DefaultAzureCredential());

X509Certificate2 cert = (await certClient
    .DownloadCertificateAsync("orders-client-auth")).Value;

var app = ConfidentialClientApplicationBuilder
    .Create(clientId)
    .WithAuthority($"https://login.microsoftonline.com/{tenantId}")
    .WithCertificate(cert, sendX5C: true)
    .Build();

// MSAL caches in memory and only round-trips Entra when the token is near expiry.
AuthenticationResult result = await app
    .AcquireTokenForClient(new[] { "api://orders-api/.default" })
    .ExecuteAsync();

httpClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", result.AccessToken);
</code></pre>
<blockquote>
<p><code>sendX5C: true</code> <strong>is the line worth knowing</strong></p>
<p>It sends the full public certificate with the assertion, which enables <strong>subject name / issuer authentication</strong>. Entra then trusts any certificate with the same subject and issuer rather than one specific thumbprint — so you can roll to a renewed certificate without touching the app registration, and old and new coexist during rollout. Without it, every renewal is a coordinated change across the registration and every deployed instance. This is the difference between a credential rotation that is routine and one that causes an outage at 2am.</p>
</blockquote>
<h2>The one-sentence version</h2>
<p>The client generates a key pair, keeps the half that can sign, and sends you the half that can only verify. You upload that to their app registration. Entra then validates signatures made by a key it has never seen — and neither you nor Microsoft can impersonate the partner, because a public certificate cannot produce a signature.</p>
<p>If your onboarding process ever requires you to <em>securely transmit</em> something to the partner, you have drifted back to shared-secret territory and lost the benefit.</p>
]]></content:encoded></item></channel></rss>