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.
Two flows, one pipeline
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.
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.
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.Live environments
Four 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.
middleware.ts rewrites everything outside the allow-list to a 404.minReplicas: 1 and sticky sessions are correctness requirements, not tuning.APP_MODE=docs, and an allow-list of exactly one route — everything else on this hostname rewrites to the handbook, so the operator console cannot be reached here. Scales to zero, so the first request after an idle period is slow.Base URLs used throughout this handbook
# Every API path in the reference is relative to $MEDIA. export MEDIA="https://api.entraguard.my" export PORTAL="https://entraguard.my" export TREASURY="https://placeholders.my"
id_token_hint Microsoft signs. Examples below that use curl need a bearer token or an operator session.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.
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.
Run the attack scenario
Replays a help-desk impersonation through the pipeline. Watch the risk climb in the console's Live blade.
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.
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
The five built-in scenarios
| Scenario id | What it stages | Expected result |
|---|---|---|
helpdesk-fraud | Caller poses as IT, manufactures urgency, coaches the user through an MFA approval and asks them to read the number back | High risk (80+), reaches about_to_approve, triggers containment |
benign-helpdesk | A real help desk that actively discourages every behaviour an attacker relies on | Low (under 40). No remediation. The false-positive control |
remote-access | Caller pushes the user to install Quick Assist and not tell their IT team | High risk, flags remote_access_tooling + authority_impersonation |
tap-request | Fake onboarding flow soliciting a Temporary Access Pass | High risk, flags temporary_access_pass_request |
ambiguous-support | A genuine agent under time pressure discussing MFA without ever soliciting a credential action | Moderate at most, and crucially low confidence — tests that pressure alone is not evidence |
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
| Result | Grants access | What happened |
|---|---|---|
Passed | Yes | Number match confirmed, any questions answered, no coercion heard. |
Failed | No | Wrong code after three attempts, or the identity questions were not answered correctly. |
BlockedCoercion | No | Risk ≥ 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. |
BlockedVoiceMismatch | No | Right digits, no coaching, wrong voice. Only reachable when VOICE_MODE=enforce; in observe mode the score is recorded and changes nothing. |
Timeout | No | The 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. |
CallFailed | No | The call never reached CallConnected. "Never rang" is a different fault from "rang and was ignored", with a different fix, so they are reported separately. |
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.
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.
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.
Operating modes
| Switch | Values | Effect | Now |
|---|---|---|---|
ENTRAGUARD_SHADOW_MODE | true / unset | When 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_TIER | graph / degraded | graph 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_MODE | observe / enforce | observe scores and records; every outcome reports no step-up required, so voice cannot refuse anybody. enforce lets the thresholds bite. | observe |
VOICE_AGENT | on / off | Opt-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_MFA | true / false | Whether a second factor must be proven before a voice profile can be enrolled. | true |
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.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.
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.
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.
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.
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.
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.
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.
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.
RiskAssessment → PolicyGate → ordered action list
Pure, no I/O, exhaustively tested. Emits a prefix of the escalation ladder, never a gap in the middle.
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.
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
| Path | What blocking it costs |
|---|---|
| Receive loop | Audio 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-out | Fire-and-forget. A slow browser must never affect a live call. |
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.
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 ofPolicyGate.Evaluate — same thresholds, same refusal text.
The escalation ladder
revokeSignInSessions. Any licensing tier — the backbone of the degraded path. If the attacker already captured a token, this is what stops them using it.irreversibleidentityProtection/riskyUsers/confirmCompromised. Requires Entra ID P2.irreversibleabout_to_approve.irreversibleconfirmCompromised 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.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.
Coercion check
Risk ≥ 60 and confidence ≥ 0.75 → BlockedCoercion, 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…".
Code match
Wrong and attempts remain → re-prompt with no hint. Wrong at attempt 3 → Failed.
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.
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.
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.
Match
Accept sits just under the weakest measured genuine pair (0.652).
Inconclusive
Does not decide. Escalates to interactive Entra re-authentication, where a passkey settles it.
Mismatch
Just above the strongest measured impostor (0.297). Step up; deny only if that also fails.
| Property | Value | Why |
|---|---|---|
| Model | SpeechBrain 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. |
| Embedding | 192-dim, L2-normalised | Cosine comparison downstream is a plain dot product, and every template is on the same scale. |
| Input | 16 kHz PCM16 mono | ACS streams 24 kHz. The 3:2 downsample averages three input samples into two outputs — naive decimation aliases sibilants back into the speech band. |
| Minimum speech | 3.0 s | ECAPA returns a vector for any input. Two seconds of "yes" looks exactly as authoritative and is far less reliable. |
| Voiced ratio floor | 5% | An embedding of a quiet room is a confident-looking number about nothing. |
| Retained audio | 60 s, newest-first | The earliest seconds of a call are prompts; the answers arrive last. |
| At rest | AES-GCM, key derived from VOICEPRINT_KEY | On a storage account that already refuses shared-key access. Never the audio. |
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.
Score a verification
A port ofVerificationRisk.Score. Deterministic and model-free, like the gate.
A second factor for any application
Contoso Treasury shows what an application gets when it integrates deliberately. An External Authentication Method is the other half: one Conditional Access policy makes the verification call a second factor for every application in a tenant — Microsoft 365, the Azure portal, gallery SaaS, the tenant’s own apps — with no change to any of them.
The obvious framing is “SAML SSO with EntraGuard as a second factor”. Microsoft built a different and better-fitting slot for it, and it is OIDC rather than SAML. A SAML IdP proxy would need every application reconfigured to trust a new identity provider, and would put EntraGuard in the path of every sign-in — if it were down, nobody would sign in at all. An external method is consulted only when a policy asks for a second factor.
Entra redirects the browser mid-sign-in with a form_post, identifying the user by an intentionally expired id_token_hint that Microsoft has signed. The signature is the authentication; the expiry is what stops the hint being replayed as a credential somewhere else. So EntraGuard validates its signature, issuer and audience, and deliberately does not validate its lifetime.
Everything after that is the ordinary verification: the phone rings, a number match, questions from live sign-in activity, and the Analyst listening for coaching throughout.
tel, never vbm. Microsoft’s vocabulary has a value that describes this product almost too well — vbm, “biometric with voiceprint”, an inherence factor. It is the wrong claim to send. Voice runs in observe mode: it is scored, recorded and shown, and it cannot refuse anybody. Sending vbm would tell Entra a biometric check passed, and Entra would grant multifactor authentication on the strength of it, while nothing about the voice can fail a sign-in. What is sent is tel — confirmation by telephone, and therefore possession, which is what ringing an enrolled endpoint and demanding a number match actually proves.That honesty has a visible consequence. If the sign-in’s first factor was already possession-based — a FIDO key, a passkey — Entra asks for inherence, which a telephone call cannot supply. EntraGuard declines before ringing anybody, rather than interrupting someone for a verification whose result could never be accepted.
The three endpoints
| Endpoint | Purpose |
|---|---|
/.well-known/openid-configuration | Discovery. The issuer must match character for character here, in every token, and in what the tenant admin configured. |
/.well-known/jwks | Public keys. x5c is mandatory — a JWKS with only n and e parses fine and is rejected. |
/api/eam/authorize | Where Entra form_posts the request. Refuses any redirect_uri Microsoft does not publish. |
Timing, and why it is tight
Entra abandons the sign-in about five minutes after redirecting. Measured across 23 real calls: median 108s, worst 156s. EntraGuard stops at 240s and returns temporarily_unavailable on its own terms, so the user gets an outcome Entra surfaces rather than a page waiting on a sign-in that was discarded at the other end.
Rotating the signing key
The rule is structural rather than procedural: publish every enabled version, sign with the oldest. Adding a version advertises it without promoting it; removing the old one is what promotes its successor. Entra caches JWKS for a day and advises assuming two, so the obvious implementation — newest signs — cannot be sequenced safely at all, because a new key would sign against a cache that has never seen it. Getting this wrong fails every sign-in behind the policy, in every tenant.
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
| Surface | Auth | Detail |
|---|---|---|
| Voice profile — user routes | Bearer | JWT 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 start | Bearer + X-Id-Token | amr 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 status | Capability | Knowing 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 callbacks | Anonymous | Called by Azure, not a browser. Event Grid proves endpoint ownership through a validation handshake before delivering anything. |
| Everything else | Anonymous | Demo posture. See the warning under Live environments. |
Rate limits
| Policy | Limit | Applies to | Partition |
|---|---|---|---|
verification-start | 10 / 10 min | POST /api/verify/start | oid claim, else X-Forwarded-For, else remote IP |
enrollment-start | 5 / hour | POST /api/voice-profile/enrollment/start | same |
429.Status codes you will actually see
| Code | Meaning here |
|---|---|
200 | Success. 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. |
202 | POST /api/simulate only. The replay runs server-side and streams over SignalR. |
400 | Missing or malformed input. Body carries { "error": "…" } naming what is required. |
401 | No or invalid bearer token on a voice-profile route. |
403 | mfa_required on enrolment start. |
404 | Unknown session, verification or enrolment id — also returned when an enrolment belongs to a different caller, so ownership cannot be probed. |
409 | already_enrolled. Re-enrol explicitly with reenroll: true to replace. |
429 | Rate limited. |
503 | /health/ready when required configuration is missing; the body lists exactly which variables. |
Health & runtime
GET/health/liveLiveness — never touches Azure
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 present
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"] }/api/diagnostics to find out whether anything actually works.GET/api/configOperating mode for the console
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"
}Diagnostics
The endpoints you call first when something is wrong. They report evidence rather than configuration.
GET/api/buildWhat is actually running
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 it
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
| Name | What it proves |
|---|---|
voiceprint | The sidecar answers and returns a 192-dimension embedding. |
voiceprint-storage | The template table is reachable and an encryption key is configured. Fails independently of the scorer. |
sentinel-ingestion | A DCE is configured. Reminds you that an undeclared column is dropped without error. |
voiceprint-circuit | Consecutive 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 meant
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
component | Telemetry · Voice · Ingestion · Graph · Acs · Realtime · Storage · Auth |
code | Stable dotted identifier, for grouping. Free text cannot be grouped. |
severity | Info · Degraded · Broken. Degraded means it worked, with a weaker mechanism. |
whatFailed / userImpact | The mechanical fact, and what the person on the call experienced. |
probableCause / remediation | A hypothesis, and a next action concrete enough to carry out. |
correlationId / subjectUpn / detail | Joins to the audit trail; raw status codes, counts, exception messages. |
POST/api/diagnostics/selftestProve the reporting path itself works
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"
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 progress
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. transcript
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.
POST/api/events/incoming-callEvent Grid → answer the call
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 lifecycle
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.
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 audio
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.
Simulation
Exercise the real detection and adjudication logic without ACS, Speech, a phone or a quiet room.
GET/api/simulate/scenariosBuilt-in conversations
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 conversation
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
| Field | Type | Notes |
|---|---|---|
scenario | string | Default helpdesk-fraud. Ignored when lines is supplied. |
subjectUpn | string? | Defaults to demo.user@contoso.com. |
subjectObjectId | string? | 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. |
lines | array? | [{ "speaker": "caller" | "user", "text": "…" }]. Your own script. |
paceMs | int | Default 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."} ] }'
POST/api/verify/simulateAdjudication without a live call
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
scenario | Stages | Result |
|---|---|---|
pass | Correct code, unremarkable audio | Passed |
wrong-code | Wrong digits at max attempts | Failed |
coerced | Correct code, risk 88 at confidence 0.91, a second voice reading digits | BlockedCoercion |
voice-mismatch | Correct code, no coaching, score 0.12 over 9.4s with enforce forced on | BlockedVoiceMismatch |
timeout | Call ended before any entry | Timeout |
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
}Step-up verification
The relying-party integration surface. Start an attempt, show the number, poll for the verdict.
Integration sequence
Check reachability (browser/phone endpoints only)
GET /api/presence/{upn}. Skip for Teams — Microsoft handles delivery and there is nothing to heartbeat.
POST /api/verify/start
Response carries matchCode and viewerToken — only on this call, and only to the caller that started it.
Display the two digits; keep the viewer token
Never log it, never put it in a URL.
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.
Gate access on grantsAccess
Not on result === "Passed" — grantsAccess is the single field that carries the decision.
POST/api/verify/startPlace the verification call
Request
| Field | Req. | Notes |
|---|---|---|
upn | yes | User principal name, used for presence and the audit record. |
teamsUserId | either | Entra object ID, not UPN. ACS addresses Teams users by directory object ID; a UPN here is accepted and then rings nobody. |
calleeAcsId | either | ACS identity of the soft-phone, from /api/acs/token. |
objectId | no | Required for the knowledge challenge — without it there is no identity to look questions up against, and the code alone decides. |
tenantId | no | From the tid claim. Needed to read the user's own directory for telemetry questions. |
applicationName | no | Spoken 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": "user@example.com", "objectId": "", "teamsUserId": " ", "tenantId": " ", "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
}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.400 when no device is registered. Teams is exempt: a Teams user is reachable by definition.GET/api/verify/{verificationId}Poll for the verdict
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 device
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 errorGET/api/verifyRecent attempts, always redacted
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?
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 leg
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.
| Event | Action |
|---|---|
CallConnected | State → Connected, speak the challenge, arm DTMF capture. |
RecognizeCompleted | Submit the collected tones for adjudication. |
PlayCompleted | Signals playback finished — lets the closing message hang up on the last word rather than a timer. |
RecognizeFailed | Not fatal. The media-stream path may still deliver the digits. |
CallDisconnected | Never answered → CallFailed; answered but no entry → Timeout. ACS 403 on a Teams leg adds federation advice; 487 means it rang and nobody picked up. |
Knowledge questions
POST/api/verify/knowledgeRegister a fallback question
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.
GET/api/verify/knowledge/{tenantId}/{objectId}Is one registered?
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.
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 heartbeat
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 now
{
"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 device
Returns 204. Called when the soft-phone page is closed or Disconnect is tapped.
ACS identity & tokens
POST/api/acs/tokenMint a VoIP token for the soft-phone
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-.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.
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?
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
}POST/api/voice-profile/calibrateDoes the model separate people?
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.
POST/api/voice-profile/rehearseDoes enrolment work, end to end?
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?
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.
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.
GET/api/voice-profile/meYour own profile status
{
"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 immediately
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 voice
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
| Code | Error | Cause |
|---|---|---|
401 | — | No valid bearer token, or missing the VoiceProfile.Manage scope. |
403 | mfa_required | No second factor proven. Registering a biometric from a password-only session would let a stolen password bind a voice to the account. |
400 | consent_required | Consent version absent or stale. |
400 | identity_mismatch | teamsUserId is not the signed-in account. Accepting an arbitrary id here would reintroduce exactly the hole the token closes. |
409 | already_enrolled | Send reenroll: true to replace. |
500 | — | No 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 progress
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 call
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.
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 events
| Event | Payload |
|---|---|
Session | sessionId, state (answered · connected · disconnected), caller, simulated, at |
Transcript | sessionId, speaker, text, offsetMs, isFinal — includes interim hypotheses |
Assessment | riskScore, confidence, stage, vectors[], evidence[], rationale, analysisLatencyMs |
Decision | effectiveRisk, summary, actions[], blocked[{action, reason}], intervening |
Remediation | action, outcome, reason, ladderRung, durationMs |
Verification | The 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();
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 route | Method | Forwards to | Notes |
|---|---|---|---|
/api/rp/config | GET | — | Served locally. teamsObjectId, teamsUpn, entraClientId, entraScope. Read at request time rather than via NEXT_PUBLIC_*, which Next inlines at build time. |
/api/acs/token | POST | /api/acs/token | Keeps token-minting on the server side of the relying party. |
/api/presence | POST | /api/presence | Device heartbeat. |
/api/presence/{upn} | GET | /api/presence/{upn} | On failure returns {any:false} — never "probably fine". |
/api/verify | GET | /api/verify | Recent attempts for the admin blade. |
/api/verify | POST | /api/verify/start | Where 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}/digits | POST | same | From the answering device. |
/api/verify/knowledge | POST GET | /api/verify/knowledge[/{tid}/{oid}] | GET takes ?tenantId=&objectId=. |
/api/simulate | GET POST | /api/simulate[/scenarios] | — |
/api/voice-profile | GET DELETE | /api/voice-profile/me | Passes Authorization and X-Id-Token through unchanged. |
/api/voice-profile/enrollment | POST | /api/voice-profile/enrollment/start | Refuses with 401 locally if no Authorization header, so an unauthenticated call never reaches the service. |
/api/voice-profile/enrollment/{id} | GET | same | — |
Pages
| Path | Host | What it is |
|---|---|---|
/ | Console | Overview — measured activity, risk distribution, remediation outcomes, capability tier |
/live | Console | Live calls, transcript, gate decisions, and the simulation launcher |
/verification | Console | Step-up attempt history with verdicts and risk contributors |
/health | Console | Dependency checks, build provenance, faults |
/ → /app | Treasury | The customer application. Sign in, move money, get verified |
/settings | Treasury | Voice enrolment, knowledge question, endpoint preference |
/phone | Treasury | Browser soft-phone. Open on the device you want to ring, tap Connect |
/live on the customer hostname returns a 404. A boundary that exists only in the navigation is not a boundary.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.
GET/healthModel loaded?
{"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 → embedding
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 similarity
{"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?
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.
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 name | Meaning |
|---|---|
mfa_fatigue_coaching | Coaching the victim to approve a push prompt they did not initiate |
otp_elicitation | Asking the victim to read back a one-time code or number-matching digits |
authority_impersonation | Claiming to be IT, the help desk, Microsoft, or a security team |
urgency_pretexting | Manufactured time pressure — account closure, breach in progress, "stay on the line" |
remote_access_tooling | Directing the victim to install or launch remote-control software |
temporary_access_pass_request | Soliciting a TAP or other bootstrap credential |
helpdesk_reset_fraud | Credential or MFA-method reset by impersonating the user to the help desk |
payment_redirect | Redirecting payment or banking details |
callback_number_swap | Moving the conversation to an attacker-controlled number or channel |
mfa_method_registration | Walking 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 · MismatchLow · Moderate (25) · Elevated (50) · High (75)
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.
RiskScore, not RiskScore_d. The _s/_d/_g suffixes are a legacy Data Collector API artifact and do not apply here.| Table | One row per | Key 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 |
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.
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
Environment variables
Injected by the Container App. No secrets in the identity path — every Azure dependency authenticates with the user-assigned managed identity.
| Variable | Purpose | Consequence if wrong |
|---|---|---|
AZURE_CLIENT_ID | Selects the user-assigned identity | Every Azure call falls back and fails |
PUBLIC_BASE_URL | A hostname reachable from outside. ACS dials it, and the external-authentication issuer is derived from it | ACS subcode 8581. Media streaming fails after the call is already up. Moving it also moves the EAM issuer, which must then be updated in every tenant that configured the method |
ACS_ENDPOINT | Call Automation and identity client | No calls answered or placed |
AI_SERVICES_ENDPOINT | Multi-service account ACS uses to render TextSource | Call connects, then "the challenge could not be played" |
SPEECH_ENDPOINT / SPEECH_REGION / SPEECH_RESOURCE_ID | Recognition. The resource ID builds the aad# authorization token | No transcript, so no analysis |
AOAI_ENDPOINT / AOAI_DEPLOYMENT / AOAI_API_VERSION | Analyst. Defaults entraguard-analyst / 2024-12-01-preview | A 400 here usually means the api-version does not accept reasoning_effort for this model |
AOAI_REALTIME_ENDPOINT / _DEPLOYMENT | Conversational agent. Empty unless VOICE_AGENT=on | Empty is a supported state — the scripted prompt path runs |
DCE_ENDPOINT / DCR_IMMUTABLE_ID | Logs Ingestion | Nothing is recorded, and a missing audit row looks exactly like an uneventful period |
LAW_RESOURCE_ID | Sentinel incident creation and console KQL | No incidents opened during the call |
STORAGE_ACCOUNT_NAME | Sessions, IdentityMap, voiceprints, transcripts | Enrolment fails; returning users get fresh ACS identities |
VOICEPRINT_URL | Internal sidecar | Voice reports NotAssessed on every call; verification itself is unaffected |
VOICEPRINT_KEY | Derives the AES-GCM key for templates | Enrolment is disabled rather than storing a biometric unencrypted. Changing it orphans every enrolled template |
VOICE_ACCEPT / VOICE_REJECT | Threshold overrides. Empty uses 0.60 / 0.35 | Thresholds from someone else's dataset refuse real users |
ENTRA_RP_CLIENT_ID / ENTRA_RP_SCOPE | Token audience for voice-profile auth | Every enrolment request is 401 |
ENTRA_SERVICE_CLIENT_ID | Multitenant app the managed identity federates into | Cross-tenant Graph silently returns empty, which reads as "this user has no history" |
ENTRA_QUARANTINE_GROUP_ID | Conditional Access quarantine target | Rung 3 reports Unavailable |
ALLOWED_ORIGINS | CORS allow-list. Unset falls back to permissive, for local development only | Wildcard origin with credentials lets any page make credentialed calls on a visitor's behalf |
APP_MODE | Portal image only. treasury hides the admin console | The relying-party boundary disappears |
CALLBACK_SIGNING_KEY | Signs the expiring capability URLs ACS callbacks and the media socket arrive on | Callbacks rejected as unsigned. Rotating it invalidates capabilities already handed out, so calls in flight lose their callback |
EVENTGRID_WEBHOOK_KEY | Authenticates the incoming-call webhook | Event Grid gets 401 and no inbound call is intercepted. Changing it means re-running the subscription script |
ENTRAGUARD_OPERATOR_IDS | Home-tenant object IDs allowed operator access, alongside the EntraGuard.Operator role | Diagnostics, simulation and the live hub return 403 |
APP_MODE | treasury, docs, or unset for the operator console. Selects which product this instance is | The wrong product on the hostname — the console reachable where a customer application should be |
EAM_KEYVAULT_URI | Vault holding the RS256 signing certificate. Unset turns the external authentication method off entirely | Discovery and JWKS return 404; the authorization endpoint declines |
EAM_CLIENT_ID | Application ID of the multitenant registration Entra was configured with. Audience of every hint received and every token issued | Every hint fails validation, so every sign-in through the method is refused |
EAM_SIGNING_CERT | Certificate name in that vault. Defaults to eam-signing | No enabled version found, so no token can be signed |
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.Azure footprint
Resource group rg-entraguard-demo, subscription , region eastus.
| Resource | Kind | Notes |
|---|---|---|
acs-entraguard- | Communication Services | Global; US data location. System-assigned identity so ACS can reach AI Services for TTS. No PSTN number purchased — IncomingCall fires for ACS-to-ACS calls just the same |
egst-entraguard-demo | Event Grid system topic | Subscription entraguard-incoming-call, 2 attempts, 1-minute TTL |
aoai-entraguard- | Azure OpenAI | entraguard-analyst = gpt-5-mini 2025-08-07, GlobalStandard, 30k TPM. Local auth disabled |
aoai-entraguard-rt- | Azure OpenAI (eastus2) | entraguard-voice = gpt-realtime-mini, 40k TPM. Realtime models are region-bound and eastus has none |
spch-entraguard- | AI Speech | Custom subdomain, local auth disabled |
ai-entraguard- | AI 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 + Sentinel | Log Analytics | 30-day retention, resource-permission access, one scheduled analytics rule |
dce-… / dcr-entraguard-demo | Monitor ingestion | Five declared streams |
stentraguard | Storage | allowSharedKeyAccess: false — identity-only data plane, so no connection string could exist to be leaked |
crentraguard | Container Registry | adminUserEnabled: false. Identity-based pulls only |
id-entraguard-demo | Managed identity | One UAMI shared by every app |
Every role assignment, and its scope
| Role | Scoped to |
|---|---|
| Contributor | The ACS resource only. ACS has no fine-grained data-plane role; scoping to the single resource keeps the blast radius here |
| Cognitive Services Speech User | The Speech account |
| Cognitive Services OpenAI User | Each OpenAI account (analyst and realtime) |
| Monitoring Metrics Publisher | The DCR only — this identity may publish these five streams and nothing else |
| Log Analytics Reader | The workspace |
| Microsoft Sentinel Contributor | The workspace |
| Storage Table / Blob Data Contributor | The storage account |
| AcrPull | The 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.
| Role | Used for |
|---|---|
IdentityRiskyUser.ReadWrite.All | Rung 1 — confirmCompromised (needs P2) |
IdentityRiskEvent.Read.All | Read risk detections |
User.RevokeSessions.All | Rung 2 — revoke refresh tokens |
GroupMember.ReadWrite.All | Rung 3 — Conditional Access quarantine |
AuditLog.Read.All | Sign-in telemetry for the knowledge challenge |
Directory.Read.All | Resolve users and tenant |
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.
| Step | What it does | Needs |
|---|---|---|
00-preflight.sh | Probes 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.sh | Subscription-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.sh | ACS 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.sh | ACR 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.sh | Subscribes 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.sh | Allow-lists this ACS resource in the Teams tenant. ACS-to-Teams calling is cross-tenant by design. | Teams tenant admin |
06-attribute-roles.sh | Custom security attribute plumbing. | Attribute admin |
07-voice-calibration.sh | Synthesises neural voices and reports genuine vs impostor separation. This is where the thresholds come from. | — |
smoke-test.sh | End-to-end verification of the deployed system. | — |
test-teams-call.sh | Places a single verification call to the configured Teams identity. | Federation done |
/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.
Troubleshooting
Symptoms that have actually happened, and what each one really was.
| Symptom | Likely cause | Do this |
|---|---|---|
| The deploy succeeded but behaviour is unchanged | Old code still serving. Has happened four separate ways, all with green health checks | GET /api/build — builtAt 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 |
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