EntraGuard Handbook
EG EntraGuard Handbook
/
Voice channel defence for Microsoft Entra ID

The phone call is inside the perimeter now.

Entra ID Protection sees the sign-in. It is blind to the phone call that caused it. EntraGuard intercepts calls placed during authentication, transcribes them live, scores them for social engineering as they happen, and acts inside the window where acting still prevents something — then turns the same pipeline around and uses a monitored call as an authentication factor of its own.

3s
Scoring cadence
2.8s
Median analyst latency
8
Remediation actions
10
Scam vectors
426
Passing unit tests

Two flows, one pipeline

Defensive · interception

An attacker calls the monitored ACS identity. Event Grid fires IncomingCall, EntraGuard answers inside the ~30 second ring window with unmixed bidirectional audio, and every three seconds an Analyst agent scores the rolling transcript. A deterministic policy gate decides what may happen — up to and including speaking a warning back down the same socket the attack is arriving on.

Offensive · step-up factor

A relying party asks EntraGuard to verify a user. It places a call to their Teams client, demands a number match on the keypad, asks questions built from their own sign-in activity minutes earlier, and passively compares their voice — while the same Analyst listens for someone coaching them through it.

The load-bearing idea. A language model reads a live conversation and proposes remediation. That is the interesting part and also the dangerous part, so the model never acts. It produces a RiskAssessment; a pure, exhaustively tested policy gate decides what is permitted. Autonomy lives upstream — authority lives in PolicyGate.cs. You can drive that gate yourself in How it decides.

Deployed today

Live environments

Three public hostnames, all in rg-entraguard-demo / eastus, all served from Azure Container Apps behind the same managed environment. The fourth has no route from the internet at all, by design.

Contoso TreasuryRelying party
https://ca-contoso-treasury.nicesky-148e5d86.eastus.azurecontainerapps.io
The customer application. Sign in with any work account, move money, and get called to prove it is you. The admin console is not reachable on this hostname — middleware.ts rewrites everything outside the allow-list to a 404.
EntraGuard consoleSecurity operations
https://ca-entraguard-portal.nicesky-148e5d86.eastus.azurecontainerapps.io
Four blades: overview, live calls, verification history, and health. Reads live Microsoft Graph, KQL and Resource Graph server-side using the managed identity.
Media serviceAPI & call plane
https://ca-entraguard-media.nicesky-148e5d86.eastus.azurecontainerapps.io
Everything in the API reference below lives here. Also hosts the ACS media WebSocket and the SignalR hub. minReplicas: 1 and sticky sessions are correctness requirements, not tuning.
Voiceprint sidecarInternal only
internal ingress — not reachable from the internet
SpeechBrain ECAPA-TDNN on internal ingress. Reachable only from inside the Container Apps environment — this service turns speech into biometric embeddings and must not be on the internet.

Base URLs used throughout this handbook

# Every API path in the reference is relative to $MEDIA.
export MEDIA="https://ca-entraguard-media.nicesky-148e5d86.eastus.azurecontainerapps.io"
export PORTAL="https://ca-entraguard-portal.nicesky-148e5d86.eastus.azurecontainerapps.io"
export TREASURY="https://ca-contoso-treasury.nicesky-148e5d86.eastus.azurecontainerapps.io"
This is a demonstration deployment, not a production tenant. The API is no longer anonymous: every endpoint is authenticated by default and an endpoint added without authorization metadata is denied rather than exposed. Identity comes from validated claims, never from a field in a request body — the object ID stopped being an input, which is what stops anyone who can reach an endpoint acting as anyone. The exceptions authenticate themselves by signature instead of by session: Event Grid by webhook key, ACS callbacks and the media socket by expiring HMAC capability URLs, and Entra’s external-authentication endpoints by the id_token_hint Microsoft signs. Examples below that use curl need a bearer token or an operator session.

Three minutes, no phone call

Quickstart

The fastest honest look at the system. Everything below runs the real Analyst, the real policy gate, the real Actuator and the real Sentinel writes — only ACS and Speech are bypassed, because the transcript is supplied rather than recognised.

1

Confirm what is actually running

An image tag says what was requested. /api/build is compiled in, so it can only say what is running.

2

Run the attack scenario

Replays a help-desk impersonation through the pipeline. Watch the risk climb in the console's Live blade.

3

Run the benign control

The one that matters. It has every surface feature of the attack — a help desk, a password reset, urgency — and must score low. A detector you only ever watch fire is a detector you cannot evaluate.

4

Read the verdict trail

Both runs are in Log Analytics within a couple of minutes, including every action the gate considered and refused.

# 1 — what is running
curl -s "$MEDIA/api/build" | jq

# 2 — the attack
curl -s -X POST "$MEDIA/api/simulate" \
  -H 'Content-Type: application/json' \
  -d '{"scenario":"helpdesk-fraud","subjectUpn":"demo.user@contoso.com","paceMs":900}'

# 3 — the false-positive control. This one must stay under 40.
curl -s -X POST "$MEDIA/api/simulate" \
  -H 'Content-Type: application/json' \
  -d '{"scenario":"benign-helpdesk","paceMs":900}'

# 4 — adjudication only, no audio needed
curl -s -X POST "$MEDIA/api/verify/simulate" \
  -H 'Content-Type: application/json' \
  -d '{"scenario":"coerced","upn":"demo.user@contoso.com"}' | jq
Watch it happen. Open the console's Live calls blade before step 2 and leave it open. Transcript, risk trajectory, gate decisions and executed actions all stream over SignalR as the replay runs. Sessions started this way are labelled Simulated everywhere they appear — a security tool that lets a replay pass for an interception is worse than one that cannot replay at all.

The five built-in scenarios

Scenario idWhat it stagesExpected result
helpdesk-fraudCaller poses as IT, manufactures urgency, coaches the user through an MFA approval and asks them to read the number backHigh risk (80+), reaches about_to_approve, triggers containment
benign-helpdeskA real help desk that actively discourages every behaviour an attacker relies onLow (under 40). No remediation. The false-positive control
remote-accessCaller pushes the user to install Quick Assist and not tell their IT teamHigh risk, flags remote_access_tooling + authority_impersonation
tap-requestFake onboarding flow soliciting a Temporary Access PassHigh risk, flags temporary_access_pass_request
ambiguous-supportA genuine agent under time pressure discussing MFA without ever soliciting a credential actionModerate at most, and crucially low confidence — tests that pressure alone is not evidence

User manual · part one

Signing in to Contoso Treasury

Contoso Treasury is a fictional customer application that has integrated EntraGuard as a step-up factor. It exists so the relying-party boundary is real rather than a navigation link — same container image as the console, different hostname, APP_MODE=treasury.

Open the Treasury hostname. You will be asked to sign in with a Microsoft work or school account — any tenant, because the app registration is multitenant. Your object ID comes from the oid claim in the token, never from a field you can edit.

Once signed in, initiate a payment. Treasury treats a high-value transfer as the moment worth protecting, so instead of waving you through it asks EntraGuard to verify you. What happens next depends on how you have told it to reach you.

Your phone rings. If a Teams identity is configured you get a Teams call from EntraGuard verification · Contoso Treasury — a real push notification that rings a locked phone, already holding microphone permission, with no page to keep open. Otherwise the browser soft-phone rings on whichever device reported presence.

A two-digit number appears on screen. Answer the call and a voice asks you to enter that number on your keypad. This is the part that defeats the attacker-on-phone / victim-at-browser split that ordinary push MFA falls to: the number lives on the screen you are looking at, and only somebody holding both can complete it. You get three attempts, and no hint about how close you were — partial feedback would shrink an already small keyspace.

Then it asks you questions. Not "your mother's maiden name". EntraGuard reads your own sign-in activity from the last few hours and asks about it: which town or city you were in the last time you signed in, and what kind of device or browser you used. Nothing is stored, so there is nothing to breach; the answers expire on their own; and an attacker who has just phoned you could not have researched them, because the question did not exist until the call started.

Answer out loud, in your own words. City, state and country all count as correct for the location question — Entra records the city an IP resolves to, and almost nobody answers with their exact suburb. You get two tries at each question, and a language model decides whether what you said means the same thing as what the directory holds.

Before the questions, you are warned. "Please make sure nobody can overhear you, and that nobody is helping you answer." Everything that follows is spoken aloud into whatever room you are standing in, so the warning belongs before the questions rather than after them.

And the whole time, the call is being scored. This is what makes it different from every other MFA channel. Number matching proves the person holding the phone is the person at the browser. It cannot prove you are acting freely — somebody standing over you saying "press four seven" satisfies number matching perfectly. So the same Analyst that watches attack calls watches this one, and if it hears coaching the attempt is refused with BlockedCoercion even though the digits were right.

You will hear the outcome before the line drops. Silence after a refusal is how a legitimate user concludes the system is broken rather than protecting them.

What each outcome means

ResultGrants accessWhat happened
PassedYesNumber match confirmed, any questions answered, no coercion heard.
FailedNoWrong code after three attempts, or the identity questions were not answered correctly.
BlockedCoercionNoRisk ≥ 60 at confidence ≥ 0.75 during the call. Somebody was guiding you through it. The closing message tells you to hang up and contact your security team.
BlockedVoiceMismatchNoRight digits, no coaching, wrong voice. Only reachable when VOICE_MODE=enforce; in observe mode the score is recorded and changes nothing.
TimeoutNoThe call was answered but ended before a code was entered. A hang-up before a verdict is a failure, not a pass — anything else would make dropping the call a way past the factor.
CallFailedNoThe call never reached CallConnected. "Never rang" is a different fault from "rang and was ignored", with a different fix, so they are reported separately.
User manual · part two

Choosing what rings

Three endpoint kinds can take the call, and they are not equally strong. Teams is a managed, signed-in application on a device the tenant knows. A browser soft-phone is whatever tab happened to be open, and an attacker can open a tab — so a browser verification carries a small risk penalty in the audit record even when it passes.

Teams is preferred wherever it is available and needs nothing from you: Microsoft handles delivery, including to a locked phone. Note that ACS addresses Teams users by directory object ID, not UPN — a UPN here is accepted and then rings nobody.

The browser soft-phone needs the page open and the microphone allowed. Open /phone on the device you want to ring and tap Connect. That device then heartbeats every ten seconds, and EntraGuard refuses to start a verification if no device is currently registered — because placing a call to an identity nobody is listening on produces the worst possible experience: ACS accepts it, no device rings, no callback ever arrives, and you watch "Calling…" until you give up.

Each device gets its own ACS identity. ACS forks an incoming call to every endpoint registered on an identity and the first to accept wins — when a desktop and a handset shared one, the desktop's automatic accept answered instantly and the phone never rang, while the interface correctly reported the call as answered.

A readiness check that cannot fail is not a check. Presence was originally inferred by asking the token broker for an ACS identity — which always returns one, whether or not a device is registered on it. Presence now has to be reported by the device that will answer.
User manual · part three

Enrolling your voice

Optional, reversible, and the only factor that speaks to who is on the call rather than what they hold or know.

Open Settings in Contoso Treasury. Enrolment requires you to be signed in and to have proven a second factor — registering a biometric from a password-only session would let anyone with a stolen password bind their own voice to your account, turning a leaked credential into a permanent one. The proof is your ID token, validated server-side and cross-checked to be the same subject, because amr cannot be delivered in an access token at all.

You then consent explicitly, to a versioned text. The version is stored with the profile, so it is always answerable which wording a given person agreed to — "they consented" is not a defensible record once the terms have been rewritten.

EntraGuard calls you and asks you to read three randomly chosen phrases. It checks quality and self-consistency: a template built from recordings that disagree with each other is a template that will not match its owner. Then it averages them into a 192-dimension unit vector, encrypts it with AES-GCM, and stores it. The audio is discarded. An embedding cannot be replayed as speech; a recording can.

From then on, verification scores your voice passively — on the speech you produce answering the identity questions. No extra prompt, no extra time, and several seconds of natural speech rather than one read-aloud phrase.

Deleting is immediate and yours alone. Biometric consent that cannot be withdrawn is not consent, and a soft-delete you cannot see is not a deletion. An absent profile is invisible to you — nothing in the interface reveals whether someone else has enrolled.

What voice does not defend against. SpeechBrain has no anti-spoofing. A high-quality clone would score as the speaker. What limits replay is that the challenge is unpredictable — an attacker cannot pre-record an answer to a question that did not exist until the call began. This is also why a weak match asks for a stronger factor rather than denying: only failing that refuses.
User manual · part four

The admin console

Four blades, each answering a question an operator actually asks. Every panel reads live Graph, KQL or Resource Graph server-side through the managed identity — nothing is pre-baked.

Overview /

What the system has measured: call volume, peak risk distribution, remediation outcomes including the refusals, and the tenant's real capability tier. States the operating mode honestly rather than assuming the happy path — this is what drives the "risk elevation unavailable" banner.

Live calls /live

Streaming transcript with speaker attribution, the risk trajectory as it moves, each gate decision with the actions it authorised and withheld, and executed remediation with real outcomes. Also where Test the pipeline → Run simulation lives.

Verification /verification

Step-up attempts with verdict, reason, endpoint kind, attempts consumed, peak risk during the call, voice outcome and the deterministic risk score with its contributors in plain language.

Health /health

Dependency checks proven by calling them, not by checking that they are configured. Build provenance, the voiceprint circuit state, ingestion posture, and the fault log grouped by code.

Withheld actions are shown, not hidden. An audit trail that only records what happened, and never what was considered and declined, cannot answer "why didn't it act?" — which is the first question anyone asks after an incident it did not stop.
User manual · part five

Operating modes

SwitchValuesEffectNow
ENTRAGUARD_SHADOW_MODEtrue / unsetWhen true the full pipeline runs and the gate records everything it would have done, but only telemetry, SOC notification and Sentinel incidents are executed. This is how you pilot it in a real tenant.off
ENTRAGUARD_RISK_TIERgraph / degradedgraph means Entra ID P2 is present and confirmCompromised runs for real. degraded substitutes Conditional Access quarantine and records exactly why. Resolved once by preflight, not discovered mid-incident.degraded
VOICE_MODEobserve / enforceobserve scores and records; every outcome reports no step-up required, so voice cannot refuse anybody. enforce lets the thresholds bite.observe
VOICE_AGENTon / offOpt-in conversational realtime agent on verification calls. When off, a scripted prompt is played and DTMF capture is armed exactly the same way.off
VOICE_REQUIRE_MFAtrue / falseWhether a second factor must be proven before a voice profile can be enrolled.true
The default under-claims. An unconfigured deployment defaults to degraded rather than promising a risk elevation it cannot deliver. When rung 1 returns 403 the console says so in the API's own words instead of showing a green tick — a demo that fakes a successful risk elevation is a demo that falls apart under the first informed question.

How it decides · one

The interception pipeline

Ten steps from a ringing phone to a spoken warning. Steps three and four are latency-critical: a call rings for roughly thirty seconds and everything between the Event Grid notification and AnswerCall runs inside that budget.

01

Attacker calls the monitored ACS identity

No PSTN number is purchased. IncomingCall fires for ACS-identity to ACS-identity calls exactly as it does for PSTN.

02

ACS → Event Grid: Microsoft.Communication.IncomingCall

Two delivery attempts, one-minute TTL. Retrying a stale event past the ring window just answers calls nobody is on any more.

03

POST /api/events/incoming-call — answer fast

Correlation IDs seen in the last two minutes are dropped: Event Grid delivers at least once, and answering twice fails noisily and looks exactly like a broken demo. Nothing slow belongs here — no database lookups, no Graph calls.

04

AnswerCall with unmixed bidirectional media

Unmixed gives one channel per participant with a participantRawID, which is what lets EntraGuard attribute coaching language to the caller rather than the victim. EnableBidirectional is what makes the spoken warning possible. Pcm24KMono, DTMF on.

05

ACS dials back wss://…/ws/media/{sessionId}

Our side is the WebSocket server. This is why the service runs on Container Apps and not Functions, which cannot accept an inbound upgrade at all.

06

PCM frames → per-channel recogniser → attributed transcript

Silent frames are skipped rather than forwarded, so recognition is not billed for silence. Speaker attribution is a property of the media stream, not an inference the model could get wrong.

07

Every 3s: transcript window → Analyst

Azure OpenAI with a strict JSON schema and reasoning_effort: "low". At default effort the same call took 15–16 seconds — long enough that the warning would arrive after the victim had already approved.

08

RiskAssessmentPolicyGate → ordered action list

Pure, no I/O, exhaustively tested. Emits a prefix of the escalation ladder, never a gap in the middle.

09

ActuatorAgent executes in order

Sequentially, not in parallel: a hang-up racing the warning that is meant to precede it would silence the warning. Each outcome is recorded verbatim, including the refusals.

10

Warning audio streams back down the socket from step 5

24 kHz PCM16 framed at 20 ms per packet to match ACS's own framing. The user hears it while the attacker is still talking — every other remediation is invisible to the person currently being manipulated.

Three concurrency paths that must not block each other

PathWhat blocking it costs
Receive loopAudio backs up and the transcript falls behind the live conversation.
Analysis loop (PeriodicTimer, 3s)Model latency lives here, off the media path. A failure means the previous verdict stands and the next pass retries.
SignalR fan-outFire-and-forget. A slow browser must never affect a live call.
Never remediate during EntraGuard's own call. The analysis loop runs on every media session, including the ones EntraGuard placed itself. Without an explicit gate the Actuator could revoke the sessions of the very user it is in the middle of calling, triggered by a transcript consisting largely of our own questions. Scoring still happens and is still recorded — what is withheld is the action.

How it decides · two

The policy gate

The deterministic safety rail between a language model and somebody's account. Every rule below encodes one judgement about asymmetric cost — changing a threshold changes who gets locked out of their account by mistake.

Effective risk is the model's raw score plus an urgency weight taken from how far the victim has been drawn in, clamped to 0–100. That weighting is what makes EntraGuard preventive rather than forensic: identical evidence justifies more force when approval is seconds away.

unaware
+0
Has not acted on the caller's instructions
engaged
+5
Following the caller's narrative
about_to_approve
+15
At the point of approving or reading a code
approved
+10
Already complied — containment, not prevention
approved weighs less than about_to_approve, deliberately. Once the victim has complied there is nothing left to prevent, only to contain, and the most disruptive action available no longer buys anything.

Drive the gate

A faithful port of PolicyGate.Evaluate — same thresholds, same refusal text.
100 / 100 effective critical
40 notify 60 warn 80 contain 90

Executes now
Withheld, and why

The escalation ladder

rung 4LogTelemetryEvery assessment, including the ones that change nothing. A gap in the telemetry is indistinguishable from the system being down.reversible
rung 4RaiseSentinelIncidentOpened directly during the call. The scheduled analytics rule also produces incidents, but on a five-minute cadence — too slow to appear while the call is still up.reversible
rung 4NotifySocReal-time alert to the console feed.reversible
rung 4InjectVoiceWarningThe only action that reaches the victim inside the attack window, and the reason bidirectional streaming is required rather than receive-only.reversible
rung 2RevokeSessionsGraph revokeSignInSessions. Any licensing tier — the backbone of the degraded path. If the attacker already captured a token, this is what stops them using it.irreversible
rung 3QuarantineUserAdds the user to a group a Conditional Access policy binds to phishing-resistant MFA or an outright block.irreversible
rung 1ElevateUserRiskGraph identityProtection/riskyUsers/confirmCompromised. Requires Entra ID P2.irreversible
rung 4TerminateCallHang up. The most disruptive action and the hardest to justify after the fact — needs risk ≥ 90 and about_to_approve.irreversible
Positioning worth being precise about. Entra's "Report suspicious activity" is a human reporting an MFA prompt they did not expect. confirmCompromised is the supported programmatic equivalent. EntraGuard files that report autonomously, from evidence the user does not have — because the user is, at that moment, being actively manipulated.

How it decides · three

Verification adjudication

A pure function of (expected code, entered code, attempts, Analyst verdict, voice decision). Extracted from the ACS callback path so the security-critical logic is unit-testable, and so the simulator exercises the real rules rather than a parallel copy that could drift.

Coercion is evaluated before the code, and the order is the whole security property. A coached user normally enters the correct digits — that is what coaching produces — so checking the code first and returning early on a match would let every coerced attempt straight through.

1

Coercion check

Risk ≥ 60 and confidence ≥ 0.75BlockedCoercion, regardless of whether the digits were right. The reason text says which it was: "The code was correct, but…" or "The code was incorrect, and separately…".

2

Code match

Wrong and attempts remain → re-prompt with no hint. Wrong at attempt 3Failed.

3

Voice gate

Gated on RequiresStepUp rather than the outcome directly, because that flag already carries VOICE_MODE. NotAssessed never sets it, so no profile, an unreachable scorer, or too little speech all leave the verification exactly as it was.

4

Knowledge continuation

A correct code is necessary but may not be sufficient. If live telemetry questions can be built the call continues rather than granting here — and every question must pass.

The registered question rides along with the telemetry ones, not instead of them. On its own a stored secret is the weak factor NIST SP 800-63-3 rejects — researchable, permanent, often already breached. Combined, the challenge asks for something an attacker cannot prepare (this morning's sign-in) and something they cannot observe from the call. Defeating one is plausible; defeating both in the same minute is a different problem.
How it decides · four

Voice biometrics

Three bands, not two. Over a phone codec the genuine and impostor distributions overlap, and a single threshold forces every ambiguous call into either admitting a stranger or locking the owner out of their own money.

score ≥ 0.60

Match

Accept sits just under the weakest measured genuine pair (0.652).

0.35 – 0.60

Inconclusive

Does not decide. Escalates to interactive Entra re-authentication, where a passkey settles it.

score ≤ 0.35

Mismatch

Just above the strongest measured impostor (0.297). Step up; deny only if that also fails.

PropertyValueWhy
ModelSpeechBrain ECAPA-TDNN (Apache 2.0)Baked into the image at build time, so a cold replica does not stall a live call downloading eighty megabytes.
Embedding192-dim, L2-normalisedCosine comparison downstream is a plain dot product, and every template is on the same scale.
Input16 kHz PCM16 monoACS streams 24 kHz. The 3:2 downsample averages three input samples into two outputs — naive decimation aliases sibilants back into the speech band.
Minimum speech3.0 sECAPA returns a vector for any input. Two seconds of "yes" looks exactly as authoritative and is far less reliable.
Voiced ratio floor5%An embedding of a quiet room is a confident-looking number about nothing.
Retained audio60 s, newest-firstThe earliest seconds of a call are prompts; the answers arrive last.
At restAES-GCM, key derived from VOICEPRINT_KEYOn a storage account that already refuses shared-key access. Never the audio.
The buffer stops while EntraGuard is speaking. The prompt comes back on the callee's own channel — Teams echoes it, or the handset speaker feeds its microphone — and that channel is mapped to the protected user. Every question and privacy notice was landing in the biometric buffer as though the user had said it, which is why a genuine enrolled speaker scored 0.0004 against a calibrated genuine band of 0.65–0.88. Most of what was being compared genuinely was a different speaker: a synthetic one.
How it decides · five

Verification risk

Distinct from the verdict, and deliberately so. The verdict answers "was this person let in?", which is binary and already decided. This answers "how much did this attempt look like an attack?", which is a gradient — and it is useful precisely for the attempts that passed.

It changes nothing. No caller may use this to refuse access; the adjudicator does not see it and has no parameter for it. Every weight is a guess until real calls justify it, and this project has already locked its own owner out once by enforcing numbers measured against synthesised audio. Turning it into a gate is a separate decision that should follow evidence — and the evidence is what this produces.

Score a verification

A port of VerificationRisk.Score. Deterministic and model-free, like the gate.
0 / 100 low
25 moderate 50 elevated 75 high
What drove the number

API reference

Conventions & authentication

All paths are relative to the media service base URL. JSON in, JSON out. Use the filter in the top bar to find any endpoint on this page.

Authentication model

SurfaceAuthDetail
Voice profile — user routesBearerJWT from Entra, audience ENTRA_RP_CLIENT_ID or api://{clientId}, must carry scope VoiceProfile.Manage. Authority is organizations — sign-in is multitenant, so a user's token is issued by their directory. Both v1 (sts.windows.net) and v2 (login.microsoftonline.com/…/v2.0) issuer forms are accepted.
Enrolment startBearer + X-Id-Tokenamr cannot be delivered in an access token — Entra emits it in ID tokens only — so MFA evidence is the ID token, validated server-side and cross-checked to be the same subject.
Verification statusCapabilityKnowing the 64-bit CSPRNG verification id is the capability. An optional X-Verification-Token header unlocks the match code; presenting a wrong one is refused, presenting none falls back to the id being the secret.
ACS & Event Grid callbacksAnonymousCalled by Azure, not a browser. Event Grid proves endpoint ownership through a validation handshake before delivering anything.
Everything elseAnonymousDemo posture. See the warning under Live environments.

Rate limits

PolicyLimitApplies toPartition
verification-start10 / 10 minPOST /api/verify/startoid claim, else X-Forwarded-For, else remote IP
enrollment-start5 / hourPOST /api/voice-profile/enrollment/startsame
Why these exist. Nothing bounded how many verification calls could be started against one person. Each one rings their phone, so an unbounded loop is MFA fatigue delivered by telephone — the exact social-engineering pressure this product exists to detect, available as an anonymous HTTP request. Partitioning by UPN where the body carries one avoids punishing everyone behind a corporate NAT for one abuser. Exceeding a limit returns 429.

Status codes you will actually see

CodeMeaning here
200Success. Note that several endpoints return 200 with a failure described in the body — a verification whose call could not be placed is a real verification with result CallFailed, not an HTTP error.
202POST /api/simulate only. The replay runs server-side and streams over SignalR.
400Missing or malformed input. Body carries { "error": "…" } naming what is required.
401No or invalid bearer token on a voice-profile route.
403mfa_required on enrolment start.
404Unknown session, verification or enrolment id — also returned when an enrolment belongs to a different caller, so ownership cannot be probed.
409already_enrolled. Re-enrol explicitly with reenroll: true to replace.
429Rate limited.
503/health/ready when required configuration is missing; the body lists exactly which variables.
API reference

Health & runtime

GET/health/liveLiveness — never touches Azureanon

Always 200 {"status":"alive"} while the process is up. Deliberately independent of every Azure dependency: a Speech outage must not cause Container Apps to restart a replica that is holding live calls.

GET/health/readyReadiness — configuration presentanon

Checks four configuration values are non-empty: ACS_ENDPOINT, PUBLIC_BASE_URL, AOAI_ENDPOINT, SPEECH_ENDPOINT. Returns 503 with the missing list otherwise.

503 response
{ "status": "not-ready", "missing": ["PUBLIC_BASE_URL"] }
This answers a weaker question than it looks. A URL being set means somebody typed a URL. Every real failure in this system happened with configuration perfectly in place — use /api/diagnostics to find out whether anything actually works.
GET/api/configOperating mode for the consoleanon

Lets the console state the operating mode honestly rather than assuming the happy path. This is what drives the "risk elevation unavailable" banner.

Response
{
  "riskTier": "Degraded",
  "autonomousActionsEnabled": true,
  "analysisIntervalMs": 3000,
  "analysisWindowMs": 45000,
  "model": "entraguard-analyst"
}
API reference

Diagnostics

The endpoints you call first when something is wrong. They report evidence rather than configuration.

GET/api/buildWhat is actually runninganon

The single most useful endpoint here, because of how often the answer has been "not what you just built". A deploy has reported success while old code kept serving four separate times: a comment inside a backslash-continued command silently ended it; a compile error broke the image build; an invalid flag meant no image was ever pushed; and a draining revision answered probes for minutes after the new tag was live. Health checks stayed green every time, because the old container was perfectly healthy.

An image tag says what was requested. This is compiled in, so it can only say what is running.

Response
{
  "version": "1.0.0.0",
  "informational": "1.0.0",
  "builtAt": "2026-08-14T13:32:04Z",
  "startedAt": "2026-08-14T13:33:16.72+00:00",
  "uptimeSeconds": 283,
  "voiceMode": "observe",
  "voiceAccept": 0.6,
  "voiceReject": 0.35,
  "autonomousActions": true,
  "riskTier": "Degraded",
  "model": "entraguard-analyst"
}
GET/api/diagnosticsEvery dependency, proven by calling itanon

Four checks. The voiceprint check embeds a real 4-second tone and asserts the shape of what comes back — "configured" would be satisfied by a typo. Worst status wins: a single broken dependency is not averaged away by three healthy ones.

Checks performed
NameWhat it proves
voiceprintThe sidecar answers and returns a 192-dimension embedding.
voiceprint-storageThe template table is reachable and an encryption key is configured. Fails independently of the scorer.
sentinel-ingestionA DCE is configured. Reminds you that an undeclared column is dropped without error.
voiceprint-circuitConsecutive failures, last success, and whether calls are currently being shed. Needs no call.
Response
{
  "status": "healthy",           // healthy | degraded | broken
  "checks": [
    { "name": "voiceprint", "status": "healthy",
      "finding": "Returned a 192-dimension embedding.",
      "action": "No action needed." }
  ],
  "activeSessions": 0,
  "verificationsInFlight": 0,
  "degradedCapabilities": [],
  "voiceMode": "observe"
}
GET/api/diagnostics/faultsWhat went wrong and what it meantanon

Grouped by code rather than listed raw, because the question is almost never "did this happen once" — it is "is this happening repeatedly, since when, and does it matter". Each entry carries the user impact, the probable cause and the next action.

In-memory and bounded, so it is empty after a restart. Anything that must survive one is in EntraGuard_Fault_CL.

Fields per entry
componentTelemetry · Voice · Ingestion · Graph · Acs · Realtime · Storage · Auth
codeStable dotted identifier, for grouping. Free text cannot be grouped.
severityInfo · Degraded · Broken. Degraded means it worked, with a weaker mechanism.
whatFailed / userImpactThe mechanical fact, and what the person on the call experienced.
probableCause / remediationA hypothesis, and a next action concrete enough to carry out.
correlationId / subjectUpn / detailJoins to the audit trail; raw status codes, counts, exception messages.
POST/api/diagnostics/selftestProve the reporting path itself worksanon

The one check nothing else can make. Every other mechanism reports failures through the fault recorder, so if the recorder or its stream is broken the system goes quiet in exactly the way it would if everything were fine. That is not hypothetical — a data collection rule silently dropped five columns for days, and the symptom was a dashboard reading zero, which is also what "nothing happened" looks like.

Records a clearly-labelled Info fault and returns a marker. If it appears in memory but never in EntraGuard_Fault_CL within ten minutes, the DCR stream declaration does not match the row shape.

Response
{
  "raised": "selftest-134812",
  "visibleInMemory": true,
  "next": "Query EntraGuard_Fault_CL for Detail == 'selftest-134812'…"
}
Then confirm it landed
EntraGuard_Fault_CL
| where Detail == "selftest-134812"
API reference

Sessions & interception

The read APIs the console calls, plus the two inbound webhooks and the media socket that Azure itself drives.

GET/api/sessions/liveCalls in progressanon

Exists so a page load has something to render before the first SignalR event arrives, and so live state is inspectable with curl when a demo misbehaves.

Response — array of
{
  "sessionId": "3f9c…", "startedAt": "…",
  "subjectUpn": "sam@contoso.com",
  "callerIdentity": "8:acs:…",
  "riskScore": 72, "peakRisk": 87, "confidence": 0.9,
  "stage": "AboutToApprove",
  "vectors": ["OtpElicitation", "AuthorityImpersonation"],
  "isActive": true,
  "actionsTaken": ["LogTelemetry", "NotifySoc", "InjectVoiceWarning"]
}
GET/api/sessions/{sessionId}Full detail incl. transcriptanon

Adds subjectObjectId, acsCorrelationId, endedAt, the full assessment with evidence spans and rationale, and the complete final transcript with speaker and offset. 404 if the session has been retired.

Live transcript is never persisted. It exists in memory and over SignalR only. The full transcript archive goes to blob storage; Sentinel gets the structured verdict and evidence spans, not the raw conversation.
POST/api/events/incoming-callEvent Grid → answer the callAzure

Handles two things. First, the Event Grid SubscriptionValidationEvent handshake — failing it means the subscription never activates and no call is ever intercepted, with no error surfaced anywhere obvious. Second, Microsoft.Communication.IncomingCall.

Correlation IDs seen within a two-minute window are dropped: Event Grid delivers at least once and a redirected call can produce a second event for the same conversation.

Wired by
./scripts/04-eventgrid-subscribe.sh
# system topic egst-entraguard-demo → this endpoint
# --max-delivery-attempts 2  --event-ttl 1
POST/api/callbacks/{sessionId}ACS call lifecycleAzure

Separate from the Event Grid endpoint: Event Grid says a call is arriving; these say what happened to a call already answered. Handles CallConnected, MediaStreamingStarted, MediaStreamingFailed and CallDisconnected.

ACS subcode 8581 on MediaStreamingFailed almost always means PUBLIC_BASE_URL is not publicly reachable over wss. The failure surfaces here while the cause is a configuration value set somewhere else entirely.
WSS/ws/media/{sessionId}Bidirectional call audioAzure

ACS dials in to this. Inbound frames are AudioMetadata, AudioData (with participantRawID) and DtmfData; outbound is synthesised PCM framed at 960 bytes — 24 kHz 16-bit mono at 20 ms per packet, matching ACS's own framing so playback stays smooth. One oversized write arrives as a burst.

Rejects non-WebSocket requests with 400 and unknown sessions with 404. Malformed frames decode to UnknownFrame and are ignored — an exception here would drop a live call.

API reference

Simulation

Exercise the real detection and adjudication logic without ACS, Speech, a phone or a quiet room.

GET/api/simulate/scenariosBuilt-in conversationsanon

Returns id, name, description, expected and line count for each. Every scenario states what it should score, so a run that disagrees is a visible regression rather than an interesting result.

POST/api/simulateReplay a conversationanon

Returns 202 Accepted immediately with the session id; the replay continues server-side and streams to the console over SignalR. Sessions are flagged IsSimulated and labelled everywhere they appear.

Request
FieldTypeNotes
scenariostringDefault helpdesk-fraud. Ignored when lines is supplied.
subjectUpnstring?Defaults to demo.user@contoso.com.
subjectObjectIdstring?Supply this to unlock identity-plane actions. Without it the subject gate withholds revoke, quarantine and risk elevation — correctly, since there is no account to act on.
linesarray?[{ "speaker": "caller" | "user", "text": "…" }]. Your own script.
paceMsintDefault 1200. Zero runs as fast as the model allows — what you want for a test; a demo wants it paced so the gauge visibly climbs.
Custom script
curl -X POST "$MEDIA/api/simulate" -H 'Content-Type: application/json' -d '{
  "subjectUpn": "sam@contoso.com",
  "subjectObjectId": "00000000-0000-0000-0000-000000000000",
  "paceMs": 0,
  "lines": [
    {"speaker":"caller","text":"This is Daniel from the IT service desk."},
    {"speaker":"user","text":"Okay, what do you need?"},
    {"speaker":"caller","text":"Read me the number on the prompt and tap approve."}
  ]
}'
Transcript time advances at roughly speaking pace, so scoring happens on the same cadence a real call would produce — a fast replay still yields the same number of assessments.
POST/api/verify/simulateAdjudication without a live callanon

The outbound call, the TTS prompt and DTMF capture all need a real endpoint with a working microphone — that cannot run headlessly. Everything downstream of "the user pressed these digits" is identical here: the same adjudicator, the same risk scorer, the same Sentinel row, the same portal event.

Scenarios
scenarioStagesResult
passCorrect code, unremarkable audioPassed
wrong-codeWrong digits at max attemptsFailed
coercedCorrect code, risk 88 at confidence 0.91, a second voice reading digitsBlockedCoercion
voice-mismatchCorrect code, no coaching, score 0.12 over 9.4s with enforce forced onBlockedVoiceMismatch
timeoutCall ended before any entryTimeout
Response
{
  "verificationId": "vrf-…", "scenario": "coerced",
  "riskScore": 44, "riskBand": "Moderate",
  "riskContributors": ["Coercion analysis peaked at 88/100 during the call"],
  "expectedCode": "47", "enteredCode": "47",
  "result": "BlockedCoercion",
  "reason": "The code was correct, but EntraGuard detected the user was being coached…",
  "grantsAccess": false,
  "peakRiskDuringCall": 88,
  "simulated": true
}
The simulator uses the real rules, not a copy of them. A simulator running a parallel implementation eventually disagrees with the shipped one, and then it is proving something that is not shipped.
API reference

Step-up verification

The relying-party integration surface. Start an attempt, show the number, poll for the verdict.

Integration sequence

1

Check reachability (browser/phone endpoints only)

GET /api/presence/{upn}. Skip for Teams — Microsoft handles delivery and there is nothing to heartbeat.

2

POST /api/verify/start

Response carries matchCode and viewerTokenonly on this call, and only to the caller that started it.

3

Display the two digits; keep the viewer token

Never log it, never put it in a URL.

4

Poll GET /api/verify/{id} with X-Verification-Token

Until isComplete. Also exposes live media evidence: whether ACS is actually streaming, frames received, DTMF seen.

5

Gate access on grantsAccess

Not on result === "Passed"grantsAccess is the single field that carries the decision.

POST/api/verify/startPlace the verification call10/10min
Request
FieldReq.Notes
upnyesUser principal name, used for presence and the audit record.
teamsUserIdeitherEntra object ID, not UPN. ACS addresses Teams users by directory object ID; a UPN here is accepted and then rings nobody.
calleeAcsIdeitherACS identity of the soft-phone, from /api/acs/token.
objectIdnoRequired for the knowledge challenge — without it there is no identity to look questions up against, and the code alone decides.
tenantIdnoFrom the tid claim. Needed to read the user's own directory for telemetry questions.
applicationNamenoSpoken aloud and shown in Teams as the caller display name. Defaults to "the application".
Request example
curl -X POST "$MEDIA/api/verify/start" -H 'Content-Type: application/json' -d '{
  "upn": "shah@e2sabah.com",
  "objectId": "c584d7b3-0a30-4ba4-b88c-e8ba711152a9",
  "teamsUserId": "c584d7b3-0a30-4ba4-b88c-e8ba711152a9",
  "tenantId": "cf7e7a78-6601-4419-9bd4-a8bf18e5fbdf",
  "applicationName": "Contoso Treasury"
}'
Response
{
  "verificationId": "vrf-9859f21cd1c7",
  "matchCode": "47",          // this response only, then never again
  "viewerToken": "…256 bits…",  // same
  "callState": "Placing",
  "endpointKind": "teams",
  "result": "Pending", "grantsAccess": false, "isComplete": false
}
A failure to place the call is still a 200. The verification completes as CallFailed with a diagnostic reason. For a Teams endpoint, ACS reports a missing federation grant as a bare authorization failure, which reads as a bug in this service — it is not. It is the Teams tenant declining to accept calls from this ACS resource.
Presence gate. Browser and phone endpoints are refused with 400 when no device is registered. Teams is exempt: a Teams user is reachable by definition.
GET/api/verify/{verificationId}Poll for the verdictX-Verification-Token

The capability here is knowing the verification id — 64 bits of CSPRNG, never listed, never broadcast, never logged. Presenting the right X-Verification-Token additionally unlocks matchCode; presenting a wrong one is refused, because that is an attack signal rather than an old client; presenting none falls back to the id being the secret.

Even for the rightful holder the code is withheld once the attempt completes — echoing a used auth secret back into logs and browser history buys nothing.

Response
{
  "verification": {
    "verificationId": "vrf-…", "upn": "…",
    "callState": "AwaitingAnswer",   // Placing|Connected|AwaitingDigits|Adjudicating|AwaitingAnswer|Ended
    "endpointKind": "teams",
    "matchCode": "47",               // null unless entitled and still in flight
    "knowledgeQuestion": "Which town, city, or country were you in…",
    "knowledgeAttempts": 1,
    "knowledgeBacking": "telemetry+table",
    "voiceScore": 0.71, "voiceOutcome": "Match",
    "livenessOutcome": "Passed", "livenessLatencyMs": 780,
    "requiresStepUp": false,
    "result": "Pending", "reason": "",
    "grantsAccess": false, "isComplete": false,
    "attempts": 1, "peakRiskDuringCall": 12,
    "startedAt": "…", "completedAt": null, "durationMs": 0
  },
  "media": {
    "streamConnected": true,   // ACS is really streaming, not just "call placed"
    "audioFrames": 1306,
    "dtmfReceived": 2,
    "secondsSinceAudio": 1
  }
}
POST/api/verify/{verificationId}/digitsDigits from the answering deviceanon

The third input path, and the only one that cannot fail for protocol reasons. Call Automation's recogniser is PSTN-oriented; media-stream DtmfData depends on ACS forwarding tones from a browser leg. Both may work — but "the user pressed the keys and nothing happened" is the failure that makes the whole factor worthless, so the device that owns the keypad can also say so directly.

Deliberately not gated on the viewer token. That token belongs to the browser that started the sign-in; this route is posted to by the answering device, which must never hold the starter's secret — a phone that could read the code would defeat the point of showing it on the other screen.

What bounds it instead: the code is disclosed nowhere, the call must already be connected, and attempts are capped at three. Guessing is three tries against a 90-value keyspace — the same bound a physical keypad gives.

Request / errors
{ "digits": "47" }

// 400 — "The verification call has not connected yet."
// 400 — "Expected 2 digits."
// 200 — already adjudicated by another path; not an error
GET/api/verifyRecent attempts, always redactedanon

Redacted for everyone, with no way to opt in. This is the endpoint that once leaked: it returned the live match code and UPN for every in-flight verification to any anonymous caller. The projection now defaults to redaction, so a call site added later is safe unless it deliberately opts out.

GET/api/verify/telemetry-probe/{tenantId}/{objectId}Why weren't live questions asked?anon

The fallback from live telemetry to a stored question is silent by design — the call continues either way and the caller cannot hear the difference. That made a materially weaker verification indistinguishable from a strong one from the outside.

Returns no answers and no telemetry content, only whether questions could be built and why not.

Response
{
  "available": false, "questionCount": 0, "questions": [],
  "failure": "403 Forbidden",
  "signInsReturned": 0, "signInsUsable": 0, "appsSeen": "",
  "hint": "…A 403 means the user's tenant has not consented to AuditLog.Read.All, or has no Entra ID P1 — /v1.0/auditLogs/signIns is a premium endpoint."
}
POST/api/verify/callbacks/{verificationId}ACS events for the verification legAzure

Every event is logged with its ResultInformation — code, subcode and message — before dispatch. This handler used to discard all of it, which is why a call that was refused outright and a call the user ignored produced identical output. A verification factor you cannot diagnose from its logs is a factor you cannot operate.

EventAction
CallConnectedState → Connected, speak the challenge, arm DTMF capture.
RecognizeCompletedSubmit the collected tones for adjudication.
PlayCompletedSignals playback finished — lets the closing message hang up on the last word rather than a timer.
RecognizeFailedNot fatal. The media-stream path may still deliver the digits.
CallDisconnectedNever answered → CallFailed; answered but no entry → Timeout. ACS 403 on a Teams leg adds federation advice; 487 means it rang and nobody picked up.
API reference

Knowledge questions

POST/api/verify/knowledgeRegister a fallback questionanon

The answer is hashed server-side and the plaintext is never persisted or logged. Hashing in the browser would look stronger and be weaker: the client would then decide the salt and iteration count, and anything the client decides an attacker can decide too.

Request
{
  "tenantId": "…", "objectId": "…",
  "question": "What was the name of your first pet?",
  "answer": "Biscuit"
}

Returns { "backing": "table" | "memory" }. A 400 means the answer reduces to nothing once filler words are removed — pick something with a distinct word in it.

This is the fallback, and it rides last behind live telemetry questions rather than replacing them.
GET/api/verify/knowledge/{tenantId}/{objectId}Is one registered?anon

Returns { registered, question, backing }. The question text comes back; the salt and hash never do. A question is a prompt, not a secret — but everything that could verify an answer offline stays on the server.

API reference

Presence

Reported by the device that will answer, never inferred by the server that wants it to be there. A heartbeat keeps an endpoint reachable for 30 seconds — two missed beats at the 10-second cadence.

POST/api/presenceDevice heartbeatanon

Posted once the device's ACS CallAgent is live and listening, then every 10 seconds.

{ "upn": "sam@contoso.com", "acsUserId": "8:acs:…", "deviceKind": "phone" }
// → { "registered": true }
GET/api/presence/{upn}What can answer right nowanon
{
  "any": true,
  "endpoints": [
    { "deviceKind": "phone", "acsUserId": "8:acs:…", "lastSeenSecondsAgo": 4 }
  ]
}

A failed check must read as "not reachable", never as "probably fine" — the whole point of this gate is to stop a call going out to a device that cannot answer.

DELETE/api/presence/{upn}/{deviceKind}Unregister a deviceanon

Returns 204. Called when the soft-phone page is closed or Disconnect is tapped.

API reference

ACS identity & tokens

POST/api/acs/tokenMint a VoIP token for the soft-phoneanon

The GA token-broker path. ACS also supports authenticating Entra users directly against a Communication Services resource, but that is public preview and needs a tenant-level assignment, so the flag stays off and the server issues tokens instead. The user still signs in with Entra first, so the identity binding is preserved either way.

The Entra-object-ID → ACS-identity mapping is persisted in Table Storage, because the ACS identity has to be stable: a user who re-authenticates must be reachable at the same endpoint their soft-phone is already registered on.

Request
{ "objectId": "…", "upn": "sam@contoso.com", "deviceKind": "phone" }
Response
{
  "acsUserId": "8:acs:…", "deviceKind": "phone",
  "token": "eyJ…", "expiresOn": "…",
  "endpoint": "https://acs-entraguard-5dne4p.unitedstates.communication.azure.com"
}
deviceKind is load-bearing. ACS forks an incoming call to every endpoint registered on an identity and the first to accept wins. When the desktop and the handset shared one identity, the desktop's automatic accept answered instantly and the phone never rang — while the interface correctly reported the call as answered. One identity per user, per device.

Scope is VoIP only: place and receive calls, nothing else. No chat, no PSTN. Keyed on object ID where available, falling back to UPN — a UPN can be reassigned after a rename; an object ID cannot.

API reference

Voice diagnostics

Anonymous, because nothing here touches a user. Four endpoints answering four different questions — a system can pass the first and be useless.

GET/api/voice-profile/selftestDoes the pipeline carry audio?anon

Embeds two synthetic tones (220 Hz and 660 Hz, 4s each) and reports the shapes and scores. Establishes that bytes go in and distinct numbers come out.

{
  "configured": true, "reachable": true, "dimensions": 192,
  "selfScore": 1.0,      // must be 1
  "crossScore": 0.31,    // below it → the model distinguishes inputs
  "storageReady": true, "mode": "observe",
  "accept": 0.6, "reject": 0.35
}
Not an accuracy figure. These are tones, not speech. Neither number is a speaker-verification result — only real enrolled speech gives that.
POST/api/voice-profile/calibrateDoes the model separate people?anon

A different and much more important claim than the self-test. Synthesises several Azure neural voices, treats each as a speaker, and reports same-speaker scores against different-speaker scores — producing the thresholds rather than inheriting them from a benchmark run on studio recordings.

Measured on this deployment: genuine 0.652–0.865, impostor −0.039–0.297, margin 0.355.

Read the separation as an optimistic bound. Synthesised voices are cleaner than telephony and more distinct from each other than two colleagues with the same accent. The margin will narrow on real calls, which is why enforcement stays off until scores from real verifications say otherwise.
POST/api/voice-profile/rehearseDoes enrolment work, end to end?anon

Runs the whole enrolment pipeline on synthesised speech: quality gates, self-consistency, template construction, encryption, a storage round trip, genuine and impostor scoring, and deletion. Everything except ACS.

It additionally scores an impostor speaking the same sentence as the enrolled speaker — 0.879 versus 0.011 — which is what shows the model keys on the voice and not the words.

Writes under a reserved all-zero tenant and cleans up, so no real profile can be touched.

POST/api/voice-profile/acs-smokeDoes the ACS half work?anon

Covers what the rehearsal cannot: placing a real Call Automation call, CallConnected, PlayCompleted timing, the media socket attaching, and the state machine reaching a terminal state instead of hanging.

The callee is a throwaway ACS identity answered automatically by this service's own IncomingCall handler. It produces no speech, so enrolment is expected to end in the no-usable-audio failure — and that is the point. Reaching that failure proves the prompts played, the socket attached, the quality gate ran and the retry bound terminated the call. A hang would prove the opposite.

API reference

Voice profiles

The only authenticated surface in the service, and it is authenticated because of what sits behind it. Identity comes from the validated token, never from the request body.

Why this is the line. Every other endpoint identifies a user by a string in the request body. For voice enrolment that would mean an attacker could enrol their own voice against somebody else's account, after which the biometric check confirms the attacker and refuses the owner. A stolen password can be changed. This cannot.
GET/api/voice-profile/meYour own profile statusBearer
{
  "enrolled": true,
  "consentVersion": "2026-08-10.v1", "consentAt": "…",
  "enrolledAt": "…",
  "quality": 0.91,          // min pairwise agreement between utterances
  "currentConsentVersion": "2026-08-10.v1",
  "upn": "sam@contoso.com"
}

The template itself is never returned. It is biometric data, and an endpoint that hands it back turns any stolen session into a permanent copy of it.

DELETE/api/voice-profile/meWithdraw consent immediatelyBearer

Unconditional, immediate, and available to the user themselves. Biometric consent that cannot be withdrawn is not consent, and a soft-delete the user cannot see is not a deletion.

Writes a Deleted event to EntraGuard_Biometric_CL. Under GDPR Article 9 the withdrawal of consent is the event a regulator asks about first.

POST/api/voice-profile/enrollment/startEnrol a voiceBearer + ID5/hr
Headers
Authorization: Bearer <access token, scope VoiceProfile.Manage>
X-Id-Token:    <ID token — carries amr, the MFA evidence>
Request
{
  "consentVersion": "2026-08-10.v1",  // must match exactly; absent = no consent
  "teamsUserId": "<must equal the signed-in oid>",
  "reenroll": false
}
Refusals, and what each means
CodeErrorCause
401No valid bearer token, or missing the VoiceProfile.Manage scope.
403mfa_requiredNo second factor proven. Registering a biometric from a password-only session would let a stolen password bind a voice to the account.
400consent_requiredConsent version absent or stale.
400identity_mismatchteamsUserId is not the signed-in account. Accepting an arbitrary id here would reintroduce exactly the hole the token closes.
409already_enrolledSend reenroll: true to replace.
500No encryption key configured. Refuses rather than storing a biometric unencrypted.

On success EntraGuard calls the user's Teams client and asks for three randomly chosen phrases. Returns the enrolment session description immediately; poll for progress.

GET/api/voice-profile/enrollment/{enrollmentId}Enrolment progressBearer

Returns 404 — not 403 — when the enrolment belongs to a different caller, so ownership cannot be probed. Enrolment ids are guessable enough that this check has to exist; without it one user could watch another's enrolment.

POST/api/voice-profile/callbacks/{enrollmentId}ACS events for the enrolment callAzure

Anonymous, like the verification callbacks: ACS calls this, not a browser, and it presents no user token. The enrolment id grants nothing beyond advancing a call already in flight for a user who authenticated to start it. Handles CallConnected, PlayCompleted (phrase spoken) and CallDisconnected.

API reference

SignalR live feed

Server-to-client only. There are no client-callable methods, so a compromised browser session cannot use this channel to influence a live call — the console observes, and every action originates from the policy gate.

HUB/hubs/liveSix server-pushed eventsanon
EventPayload
SessionsessionId, state (answered · connected · disconnected), caller, simulated, at
TranscriptsessionId, speaker, text, offsetMs, isFinal — includes interim hypotheses
AssessmentriskScore, confidence, stage, vectors[], evidence[], rationale, analysisLatencyMs
DecisioneffectiveRisk, summary, actions[], blocked[{action, reason}], intervening
Remediationaction, outcome, reason, ladderRung, durationMs
VerificationThe full verification projection — always match-code redacted, since the hub reaches every connected client
Connect
import * as signalR from '@microsoft/signalr';

const hub = new signalR.HubConnectionBuilder()
  .withUrl(`${MEDIA}/hubs/live`)
  .withAutomaticReconnect()
  .build();

hub.on('Assessment', a => console.log(a.riskScore, a.stage));
hub.on('Decision',   d => console.log(d.summary, d.blocked));
await hub.start();
In-process with no backplane, which is sufficient because the media service runs sticky with a small replica count. Scaling past this means Azure SignalR Service — without touching the hub itself.
API reference

Portal proxy routes

The console and Treasury expose a thin Next.js proxy over the media service. The browser never calls the media service directly: that would need it exposed as a CORS-allowed origin, and a start-a-call endpoint should not be reachable from any page on the internet.

Portal routeMethodForwards toNotes
/api/rp/configGETServed locally. teamsObjectId, teamsUpn, entraClientId, entraScope. Read at request time rather than via NEXT_PUBLIC_*, which Next inlines at build time.
/api/acs/tokenPOST/api/acs/tokenKeeps token-minting on the server side of the relying party.
/api/presencePOST/api/presenceDevice heartbeat.
/api/presence/{upn}GET/api/presence/{upn}On failure returns {any:false} — never "probably fine".
/api/verifyGET/api/verifyRecent attempts for the admin blade.
/api/verifyPOST/api/verify/startWhere a production deployment would validate the RP session cookie.
/api/verify/{id}GET/api/verify/{id}Threads X-Verification-Token through explicitly, not by blanket header copy a refactor could quietly break.
/api/verify/{id}/digitsPOSTsameFrom the answering device.
/api/verify/knowledgePOST GET/api/verify/knowledge[/{tid}/{oid}]GET takes ?tenantId=&objectId=.
/api/simulateGET POST/api/simulate[/scenarios]
/api/voice-profileGET DELETE/api/voice-profile/mePasses Authorization and X-Id-Token through unchanged.
/api/voice-profile/enrollmentPOST/api/voice-profile/enrollment/startRefuses with 401 locally if no Authorization header, so an unauthenticated call never reaches the service.
/api/voice-profile/enrollment/{id}GETsame
The portal is not the security boundary. The authenticated proxy passes the bearer token through unchanged and lets the media service validate it. A check in the proxy would only be a second place to get it wrong.

Pages

PathHostWhat it is
/ConsoleOverview — measured activity, risk distribution, remediation outcomes, capability tier
/liveConsoleLive calls, transcript, gate decisions, and the simulation launcher
/verificationConsoleStep-up attempt history with verdicts and risk contributors
/healthConsoleDependency checks, build provenance, faults
//appTreasuryThe customer application. Sign in, move money, get verified
/settingsTreasuryVoice enrolment, knowledge question, endpoint preference
/phoneTreasuryBrowser soft-phone. Open on the device you want to ring, tap Connect
The boundary is enforced, not cosmetic. In treasury mode the admin blades are not reachable at all — guessing /live on the customer hostname returns a 404. A boundary that exists only in the navigation is not a boundary.
API reference

Voiceprint sidecar

FastAPI + SpeechBrain, on internal ingress. Holds no state, stores nothing, and makes no decisions — whether a score is good enough to grant access is decided in the media service, where the policy lives and can be audited.

Not reachable from the internet. These paths are documented for completeness and for anyone running the stack locally. A model that both scores and decides is a model that can be argued into deciding wrongly; here it cannot decide at all.
GET/healthModel loaded?internal

{"status": "ready" | "loading"}. The readiness probe allows 45 seconds initial delay and 12 failures, because marking a replica ready before the model has loaded would send a live call to a service that answers 503.

POST/embedSpeech → embeddinginternal

Body is raw 16-bit little-endian PCM, 16 kHz mono — bytes rather than JSON numbers, because base64-in-JSON triples the payload for audio and this runs while somebody is holding a phone.

Peak-normalises before embedding: telephony levels vary enormously between handsets, and without it quiet callers score lower than loud ones for reasons that have nothing to do with who they are. Output is L2-normalised, so a cosine comparison downstream is a plain dot product.

# 422 if under 1.0s of audio, or if the embedding is degenerate
{ "embedding": [0.031, …], "dimensions": 192, "seconds": 9.412 }
POST/scoreCosine similarityinternal

{"a": [...], "b": [...]}{"score": 0.7214} in [−1, 1]. A 422 when the two differ in size means they were produced by different models.

POST/consistencyDo these utterances agree?internal

Body is an array of embeddings. Returns min_pairwise, mean_pairwise, the averaged unit-length template, and dimensions. Used by enrolment to check three utterances of the same person actually agree with each other before trusting their average.


Reference data

Enumerations

Scam vectors

Drawn from observed identity-attack tradecraft rather than invented for the demo — help-desk impersonation and MFA coaching are the documented entry vector behind the large 2023–2024 identity intrusions. The wire name is what the model emits; anything unrecognised is dropped rather than guessed at.

Wire nameMeaning
mfa_fatigue_coachingCoaching the victim to approve a push prompt they did not initiate
otp_elicitationAsking the victim to read back a one-time code or number-matching digits
authority_impersonationClaiming to be IT, the help desk, Microsoft, or a security team
urgency_pretextingManufactured time pressure — account closure, breach in progress, "stay on the line"
remote_access_toolingDirecting the victim to install or launch remote-control software
temporary_access_pass_requestSoliciting a TAP or other bootstrap credential
helpdesk_reset_fraudCredential or MFA-method reset by impersonating the user to the help desk
payment_redirectRedirecting payment or banking details
callback_number_swapMoving the conversation to an attacker-controlled number or channel
mfa_method_registrationWalking the victim through registering a new, attacker-controlled MFA method

Other enumerations

ComplianceStage

Ordinal values are load-bearing — the gate compares them.
unaware 0 · engaged 1 · about_to_approve 2 · approved 3

SpeakerRole

Derived from the unmixed participant channel, so it is a property of the media stream rather than an inference.
ProtectedUser · Caller · Unknown

RemediationOutcome

Succeeded · Failed · Unavailable · BlockedByPolicy
Unavailable is a known limitation — no P2, no resolved subject, no live media. Failed is a fault. The console renders them differently because an operator can act on one and not the other.

VoiceOutcome / RiskBand

NotAssessed · Match · Inconclusive · Mismatch
Low · Moderate (25) · Elevated (50) · High (75)

Reference data

Telemetry tables

Five DCR-based custom tables in log-entraguard-demo, 30-day retention, ingested through the Logs Ingestion API. Sentinel gets the verdict, not the conversation.

Column names carry no type suffix. DCR-based custom tables use explicit column names — it is RiskScore, not RiskScore_d. The _s/_d/_g suffixes are a legacy Data Collector API artifact and do not apply here.
TableOne row perKey columns
EntraGuard_CallAnalysis_CL Analyst verdict, so the risk trajectory across a call is queryable afterwards SessionId CallConnectionId AcsCorrelationId RiskScore Confidence ComplianceStage Vectors Evidence Rationale SubjectUpn SubjectObjectId CallerIdentity TranscriptWindow AnalysisLatencyMs ModelDeployment
EntraGuard_Remediation_CL Remediation attempt including refusals ActionName LadderRung Outcome Reason GraphStatusCode RiskScore DecidedBy DurationMs
EntraGuard_Verification_CL Step-up attempt. Kept separate because these are calls EntraGuard originated, not intercepted — conflating them would overstate the interception rate VerificationId ApplicationName Result Reason GrantsAccess Attempts PeakRiskDuringCall MonitorSessionId VoiceScore VoiceOutcome LivenessOutcome LivenessLatencyMs SpoofScore VoiceDetail RiskScore RiskBand RiskContributors EndpointKind
EntraGuard_Biometric_CL Consent lifecycle. Not authentication attempts — GDPR Article 9 treats a voiceprint as special category data, and the two events a regulator asks for first are when consent was given and when it was withdrawn EventType (Enrolled · ReEnrolled · EnrolmentFailed · Deleted) SubjectTenantId ConsentVersion ConsentAt PhraseCount SelfConsistency UsedMfa
EntraGuard_Fault_CL Where the system could not do what it intended and carried on anyway. The other tables record what it did; silent degradation was invisible in all of them, because the outcomes all looked fine Component Code Severity WhatFailed UserImpact ProbableCause Remediation CorrelationId Detail
The stream declaration must mirror the table exactly, and this has drifted once. Adding a column to a table without adding it to the DCR does not error — ingestion still returns 204 and the column simply arrives empty forever. Five voice columns were dropped that way: every score written since the feature shipped was discarded, the dashboard tile read zero, and the deploy, the table and the service logs all looked correct. If you add a field, add it in both places and then prove a row lands with it populated.

Sentinel analytics rule

One scheduled rule, EntraGuard — high-risk voice social engineering detected. Severity High, PT5M frequency over a PT30M period, grouped by Account with a one-hour lookback. Tactics: InitialAccess, CredentialAccess, Persistence. Techniques: T1566, T1621, T1078.

EntraGuard_CallAnalysis_CL
| where RiskScore >= 80 and Confidence >= 0.75
| summarize
    PeakRisk       = max(RiskScore),
    PeakConfidence = max(Confidence),
    Vectors        = make_set(Vectors),
    FurthestStage  = max(ComplianceStage),
    Rationale      = take_any(Rationale),
    FirstSeen      = min(TimeGenerated),
    LastSeen       = max(TimeGenerated)
    by SessionId, SubjectUpn, SubjectObjectId, CallerIdentity
| extend AccountCustomEntity = SubjectUpn, TimeGenerated = LastSeen

Scheduled rather than near-real-time: NRT rules impose constraints that custom _CL tables with dynamic columns do not reliably satisfy. The service also opens incidents directly during a live call, because five minutes is too slow to appear while the call is still up.

Reference data

KQL cookbook

Workspace log-entraguard-demo, id bff8c825-c217-4791-975e-3ee2c12823d7.

Risk trajectory of one call

EntraGuard_CallAnalysis_CL
| where SessionId == "<id>"
| project TimeGenerated, RiskScore, Confidence, ComplianceStage, Vectors, AnalysisLatencyMs
| order by TimeGenerated asc

What the gate refused, and why

EntraGuard_Remediation_CL
| where Outcome == "BlockedByPolicy"
| summarize n = count() by ActionName, Reason
| order by n desc

Analyst latency distribution — is it still preventive?

EntraGuard_CallAnalysis_CL
| summarize n = count(),
    p50 = percentile(AnalysisLatencyMs, 50),
    p90 = percentile(AnalysisLatencyMs, 90),
    max = max(AnalysisLatencyMs)

Verification outcomes by endpoint kind

EntraGuard_Verification_CL
| summarize n = count(), granted = countif(GrantsAccess == true)
    by Result, EndpointKind
| order by n desc

Voice score distribution — the data enforcement is waiting on

EntraGuard_Verification_CL
| where isnotempty(VoiceScore)
| summarize n = count(), lo = min(VoiceScore), hi = max(VoiceScore)
    by VoiceOutcome

Passed, but worth a second look

EntraGuard_Verification_CL
| where GrantsAccess == true and RiskScore >= 25
| project TimeGenerated, SubjectUpn, RiskScore, RiskBand, RiskContributors, EndpointKind
| order by RiskScore desc

Silent degradation, grouped

EntraGuard_Fault_CL
| where Severity != "Info"
| summarize n = count(), last = max(TimeGenerated), impact = take_any(UserImpact)
    by Component, Code, Severity
| order by n desc

Consent record for one person

EntraGuard_Biometric_CL
| where SubjectUpn == "sam@contoso.com"
| project TimeGenerated, EventType, ConsentVersion, ConsentAt, UsedMfa, Reason
| order by TimeGenerated asc
Reference data

Environment variables

Injected by the Container App. No secrets in the identity path — every Azure dependency authenticates with the user-assigned managed identity.

VariablePurposeConsequence if wrong
AZURE_CLIENT_IDSelects the user-assigned identityEvery Azure call falls back and fails
PUBLIC_BASE_URLThe app's own external FQDN — ACS dials it from outsideACS subcode 8581. Media streaming fails after the call is already up
ACS_ENDPOINTCall Automation and identity clientNo calls answered or placed
AI_SERVICES_ENDPOINTMulti-service account ACS uses to render TextSourceCall connects, then "the challenge could not be played"
SPEECH_ENDPOINT / SPEECH_REGION / SPEECH_RESOURCE_IDRecognition. The resource ID builds the aad# authorization tokenNo transcript, so no analysis
AOAI_ENDPOINT / AOAI_DEPLOYMENT / AOAI_API_VERSIONAnalyst. Defaults entraguard-analyst / 2024-12-01-previewA 400 here usually means the api-version does not accept reasoning_effort for this model
AOAI_REALTIME_ENDPOINT / _DEPLOYMENTConversational agent. Empty unless VOICE_AGENT=onEmpty is a supported state — the scripted prompt path runs
DCE_ENDPOINT / DCR_IMMUTABLE_IDLogs IngestionNothing is recorded, and a missing audit row looks exactly like an uneventful period
LAW_RESOURCE_IDSentinel incident creation and console KQLNo incidents opened during the call
STORAGE_ACCOUNT_NAMESessions, IdentityMap, voiceprints, transcriptsEnrolment fails; returning users get fresh ACS identities
VOICEPRINT_URLInternal sidecarVoice reports NotAssessed on every call; verification itself is unaffected
VOICEPRINT_KEYDerives the AES-GCM key for templatesEnrolment is disabled rather than storing a biometric unencrypted. Changing it orphans every enrolled template
VOICE_ACCEPT / VOICE_REJECTThreshold overrides. Empty uses 0.60 / 0.35Thresholds from someone else's dataset refuse real users
ENTRA_RP_CLIENT_ID / ENTRA_RP_SCOPEToken audience for voice-profile authEvery enrolment request is 401
ENTRA_SERVICE_CLIENT_IDMultitenant app the managed identity federates intoCross-tenant Graph silently returns empty, which reads as "this user has no history"
ENTRA_QUARANTINE_GROUP_IDConditional Access quarantine targetRung 3 reports Unavailable
ALLOWED_ORIGINSCORS allow-list. Unset falls back to permissive, for local development onlyWildcard origin with credentials lets any page make credentialed calls on a visitor's behalf
APP_MODEPortal image only. treasury hides the admin consoleThe relying-party boundary disappears
Hardening note. VOICEPRINT_KEY is currently a plain environment variable rather than a Container Apps secret reference. It derives the key protecting every biometric template, so anyone with Reader on the container app can read it from the ARM payload. Moving it to secretRef or Key Vault is one line in the Bicep and one flag in the deploy script.
Reference data

Azure footprint

Resource group rg-entraguard-demo, subscription 0d97e9f9-e299-4888-948c-2b4922428a0c, region eastus.

ResourceKindNotes
acs-entraguard-5dne4pCommunication ServicesGlobal; US data location. System-assigned identity so ACS can reach AI Services for TTS. No PSTN number purchasedIncomingCall fires for ACS-to-ACS calls just the same
egst-entraguard-demoEvent Grid system topicSubscription entraguard-incoming-call, 2 attempts, 1-minute TTL
aoai-entraguard-5dne4pAzure OpenAIentraguard-analyst = gpt-5-mini 2025-08-07, GlobalStandard, 30k TPM. Local auth disabled
aoai-entraguard-rt-5dne4pAzure OpenAI (eastus2)entraguard-voice = gpt-realtime-mini, 40k TPM. Realtime models are region-bound and eastus has none
spch-entraguard-5dne4pAI SpeechCustom subdomain, local auth disabled
ai-entraguard-5dne4pAI Services (multi-service)Not optional. ACS's TextSource requires a link to a multi-service resource; a Speech-only account is rejected for that link
log-entraguard-demo + SentinelLog Analytics30-day retention, resource-permission access, one scheduled analytics rule
dce-… / dcr-entraguard-demoMonitor ingestionFive declared streams
stentraguard5dne4pStorageallowSharedKeyAccess: false — identity-only data plane, so no connection string could exist to be leaked
crentraguard5dne4pContainer RegistryadminUserEnabled: false. Identity-based pulls only
id-entraguard-demoManaged identityOne UAMI shared by every app

Every role assignment, and its scope

RoleScoped to
ContributorThe ACS resource only. ACS has no fine-grained data-plane role; scoping to the single resource keeps the blast radius here
Cognitive Services Speech UserThe Speech account
Cognitive Services OpenAI UserEach OpenAI account (analyst and realtime)
Monitoring Metrics PublisherThe DCR only — this identity may publish these five streams and nothing else
Log Analytics ReaderThe workspace
Microsoft Sentinel ContributorThe workspace
Storage Table / Blob Data ContributorThe storage account
AcrPullThe registry

Microsoft Graph app roles

Granted directly to the managed identity, so no client secret exists anywhere — nothing to rotate, nothing to leak, nothing to accidentally commit.

RoleUsed for
IdentityRiskyUser.ReadWrite.AllRung 1 — confirmCompromised (needs P2)
IdentityRiskEvent.Read.AllRead risk detections
User.RevokeSessions.AllRung 2 — revoke refresh tokens
GroupMember.ReadWrite.AllRung 3 — Conditional Access quarantine
AuditLog.Read.AllSign-in telemetry for the knowledge challenge
Directory.Read.AllResolve users and tenant
Cross-tenant Graph uses workload identity federation. A managed identity is issued by its home directory and is meaningless in anyone else's, so every app-only call about a visiting user failed with an access denial that looked like a permissions bug and was actually an architectural one. A multitenant application registration now holds the Graph permissions, and a federated credential lets the managed identity exchange its own token for one as that application, in whichever tenant has consented. A tenant that has not consented simply fails, and callers treat that as "no data" rather than as an error — consent is the tenant's decision.

Operations

Deployment runbook

In order. Preflight is the go/no-go gate — it probes what this subscription can actually do and writes the resolved feature flags to .env.deploy, so degradation decisions are made once, up front, rather than discovered mid-demo.

az login --scope https://management.core.windows.net//.default
export PATH="$(brew --prefix dotnet@9)/bin:$PATH"
dotnet test    # 230 tests. The gate's decision matrix and the wire-format edge cases.
StepWhat it doesNeeds
00-preflight.shProbes Azure OpenAI model availability and quota (a model can be offered while your TPM allowance is zero), Entra ID P2, ACS preview eligibility, provider registration, local toolchain. Writes .env.deploy.ARM token
01-deploy-infra.shSubscription-scope Bicep. Reads back the running container images first and passes them in — the template defaults to a placeholder, and on a redeploy that default would silently revert both apps while reporting success.Contributor
02-entra-apps.shACS Clients SP, portal app registration, six Graph app roles on the managed identity, the quarantine group. Prints an admin-consent URL.Global Administrator clicks the URL
deploy-apps.shACR remote build (works from any machine with no local Docker daemon), then updates media, portal and treasury. Voiceprint only when VOICEPRINT=rebuild, because the model is baked in and rebuilding adds minutes to a loop that usually has nothing to do with voice.
04-eventgrid-subscribe.shSubscribes the media service to IncomingCall. Checks the app is serving first, because Event Grid validates the endpoint at creation time.App already deployed
05-teams-federation.shAllow-lists this ACS resource in the Teams tenant. ACS-to-Teams calling is cross-tenant by design.Teams tenant admin
06-attribute-roles.shCustom security attribute plumbing.Attribute admin
07-voice-calibration.shSynthesises neural voices and reports genuine vs impostor separation. This is where the thresholds come from.
smoke-test.shEnd-to-end verification of the deployed system.
test-teams-call.shPlaces a single verification call to the configured Teams identity.Federation done
Verify deploys by build provenance, not by HTTP 200. Health checks stay green while old code serves, because the old container is perfectly healthy. After any deploy, compare /api/build's builtAt and uptimeSeconds against what you just pushed.
curl -s "$MEDIA/api/build" | jq '{builtAt, uptimeSeconds, riskTier, voiceMode}'
az containerapp show -n ca-entraguard-media -g rg-entraguard-demo \
  --query "properties.template.containers[0].image" -o tsv

Local development

export PATH="$(brew --prefix dotnet@9)/bin:$PATH"
dotnet test
dotnet run --project src/EntraGuard.MediaService
cd src/portal && npm run dev   # :3000

Locally, DefaultAzureCredential falls through to your az login. ACS can also be driven from a connection string in development; in Azure it is always the managed identity.

Operations

Troubleshooting

Symptoms that have actually happened, and what each one really was.

SymptomLikely causeDo this
The deploy succeeded but behaviour is unchanged Old code still serving. Has happened four separate ways, all with green health checks GET /api/buildbuiltAt is compiled in and cannot lie about what is running
Call is answered, then no transcript and no analysis MediaStreamingFailed, ACS subcode 8581 — PUBLIC_BASE_URL is not reachable from ACS over wss Check the callback logs; confirm the FQDN resolves publicly and ingress allows the upgrade
Call connects, then "the challenge could not be played" AI_SERVICES_ENDPOINT missing, or ACS not linked to a multi-service Cognitive Services account A Speech-only account is not accepted for that link. Confirm ai-entraguard-* exists and ACS's identity holds Cognitive Services User on it
Teams verification ends immediately, never rings ACS 403 — the Teams tenant has not allow-listed this ACS resource, or the user is not Enterprise Voice enabled docs/teams-setup.md step 2. Note 487 means the opposite: it rang and nobody picked up
The verification call rings nobody at all A UPN was passed as teamsUserId. ACS addresses Teams users by directory object ID — a UPN is accepted and then rings nobody Pass the oid
Only the stored question is asked, not the live ones Graph refused auditLogs/signIns. A 403 means the user's tenant has not consented to AuditLog.Read.All, or has no Entra ID P1 — that endpoint is premium GET /api/verify/telemetry-probe/{tenantId}/{objectId} says exactly why. The downgrade is also recorded as a fault
A telemetry column reads empty forever The DCR stream declaration does not declare it. Ingestion returns 204 and drops it without error POST /api/diagnostics/selftest, then query EntraGuard_Fault_CL for the marker. Compare the stream against the row shape in LogsIngestionSink
Voice always reports NotAssessed No enrolled profile, under 3 seconds of speech, audio under 5% voiced, or the sidecar is unreachable GET /api/diagnostics distinguishes them. The fault record carries the specific reason
A genuine speaker scores in the impostor band The prompt is echoing back on the callee's channel and being embedded as though the user said it The biometric buffer now stops while EntraGuard speaks. Confirm with a real call, then compare against 07-voice-calibration.sh
Enrolment returns 403 mfa_required No second factor proven. amr lives only in the ID token Send X-Id-Token alongside the bearer token
The match code vanishes mid-call in the browser The viewer token was not carried through the proxy Forward X-Verification-Token explicitly. Presenting no token still works — the id is the capability — but a wrong one is refused
Every remediation reports BlockedByPolicy Shadow mode, no resolved subject, confidence under 0.75, or a verification call (which never remediates) The reason string on each row says which. Drive the gate simulator with the same inputs
Risk elevation always Unavailable Tenant has no Entra ID P2. Expected, not a bug The gate substitutes Conditional Access quarantine and records exactly why. ENTRAGUARD_RISK_TIER=degraded
"Calls in progress" never returns to zero A replay left a session active. A real call is retired by CallDisconnected; a replay has no such callback Fixed — simulated sessions now retire themselves. An operator cannot tell a stale entry from an attack actually underway, so this mattered more than it looked
The circuit is the recovery mechanism, not the failure. When the voiceprint sidecar has failed five times running, calls to it are shed for 30 seconds and resume automatically on the next successful probe. Do not restart anything. Nothing here redeploys or reconfigures anything either — self-healing that takes actions of that size on a system holding live authentication calls would be a worse failure mode than the one it was written to fix.
Colophon

About this handbook

Written from the EntraGuard source tree and verified against the deployed system: every endpoint here was read out of the route definitions rather than inferred, every threshold was taken from the constant that enforces it, and the two simulators are ports of PolicyGate.Evaluate and VerificationRisk.Score rather than descriptions of them.

Where a number is quoted as measured — analyst latency, voice separation, the calibration bands — it came from the workspace or from a calibration run, not from a projection.

Microsoft Garage Hackathon MVP · rg-entraguard-demo · eastus · gpt-5-mini