Feature parity: entra-emulator vs. real Microsoft Entra ID
Parity map as of latest-31a72a7 (the live tip of main) — tracked by git release tags. See the version history and parity changelog.
How the emulator’s surface maps to real Entra ID (as documented at
learn.microsoft.com/entra), and —
the point of this table — whether real work happens or just the API shape.
The design bet is that the durable, testable surface is protocol + real
cryptography + directory state, and those are done for real: real RS256 JWTs
that third-party validators accept, every OAuth2 grant a real MSAL speaks, real
WebAuthn ceremonies, a real SQLite directory. What is deliberately left out is
the policy engine — Conditional Access, MFA, Identity Protection — which is
what would turn a dev-loop emulator into an IdP.
“Real via our own wire-protocol implementation.” A row is 🟢 Real not
only when real cryptography does the work, but also when the emulator itself
implements Entra’s wire protocol and the logic behind it — so a real,
unmodified client (MSAL in five languages, the Graph SDK, a SCIM connector)
gets byte- and behaviour-identical responses.
Optional claims + group overage (_claim_names / _claim_sources)
Real Entra overage payload above the limit; protocol claims non-overridable. The _claim_sources endpoint is live, not decorative: getMemberObjects / getMemberGroups are served, so a client that follows the pointer really recovers the group list the token could not carry
🟢 Real
Token signing algorithm
RS256 only — which is exactly what real Entra v2.0 advertises (id_token_signing_alg_values_supported: ["RS256"], captured in e2e/golden/). ES256/PS256 are absent from Entra too, so there is no gap to close: adding them would diverge, not converge
Real: a refusal carries Entra’s own body shape, including the numeric error_codes array and the AADSTS… code in the description. Diffed against real Entra, not merely against our own reading: four refusals (invalid_client, invalid_scope, unsupported_grant_type, and an unknown client_id) were captured from a live tenant and are compared field by field on every run by internal/server/differential_test.go. The comparison’s normaliser is itself under test — TestDifferentialNormaliserDoesNotHideDifferences proves it still catches a missing field, an extra field, a wrong error code and a wrong AADSTS number while ignoring trace ids and timestamps.
Full, conformance-tested against the real Entra discovery document
🟢 Real
Instance discovery (/common/discovery/instance)
Served — MSAL calls it before every token request, and a 404 fails the whole login
🟢 Real
User realm probe (/common/UserRealm/{user})
Served, always Managed — the emulator holds every credential it can verify, so claiming Federated would send a client to an IdP that does not exist. MSAL Go probes this before it will attempt a username/password request and gives up on a non-200, so without it ROPC is unreachable from that SDK
🟢 Real
authorization_code + PKCE (S256/plain)
Real, with atomic single-use code consumption. Narrowing on exchange resolves the client’s scope vocabulary before comparing — a request for api://<app>/<scope> matches the short name the grant stored — and the response echoes the client’s own strings, because MSAL treats a requested scope missing from the response as declined. The OIDC protocol scopes every MSAL appends unconditionally are tolerated rather than counted against the grant
🟢 Real
refresh_token
Real rotation, plus family revocation on reuse — replaying a rotated token kills the whole chain. Narrowing and the scope echo behave as on the code exchange. The chain also records the authentication it descends from, not just the grant: amr and auth_time survive every rotation, so a refreshed ID token cannot disagree with the one the code exchange issued about how or when the user signed in
🟢 Real
client_credentials
Real; .default only, tolerating the stray scopes MSAL-Go/azidentity send
🟢 Real
password (ROPC)
Real scrypt verification → amr:["pwd"]; the user-realm probe MSAL Go requires first is served too
Real; enforces assertion audience, rejects app-only assertions, and carries the user through to the downstream token. The response echoes the scopes as the client asked for them rather than the short names — MSAL Go treats a requested scope missing from the response as declined and fails the acquisition
🟢 Real
Device code (spec form and the bare device_code msal-node sends)
Real, with an atomic approve→mint step that closes the double-mint window
🟢 Real
private_key_jwt client assertion
Real: assertion verified against the app’s registered certificate, advertised in token_endpoint_auth_methods_supported so a spec-driven client actually attempts it. Both RS256 and PS256 are accepted — MSAL Go signs client assertions with PS256 by default, so RS256-only verification refuses Microsoft’s own Go client. none and the HMAC algorithms are refused
🟢 Real
RP-initiated logout (end_session_endpoint)
Real: clears the SSO session and honours a validatedpost_logout_redirect_uri + state; advertised via http_logout_supported
🟢 Real
Front-channel logout (OP calls each RP’s frontchannel_logout_uri)
Real: apps register a logout URI, the emulator records which apps each SSO session signed into, and logout renders one hidden iframe per signed-into RP carrying iss and sid. Apps the session never used are deliberately not notified. Now advertised, because it now happens
🟢 Real
Implicit / hybrid flow
Real: response_type=id_token and code id_token mint a genuine signed ID token at the authorize endpoint, delivered by fragment or form_post with the nonce echoed. OIDC’s rules are enforced — a nonce is required, response_mode=query is refused for an id_token, and PKCE is demanded only when a code is actually issued. id_token token is not implemented and so is not advertised
🟢 Real
max_age + auth_time
Real: a session older than the client’s max_age is not reused — the end-user is actively re-authenticated, on the emulator’s controllable clock, so a one-second ceiling is testable without a sleep. prompt=none over a stale session answers login_required rather than quietly reusing it, and a max_age that is not a non-negative integer is refused rather than ignored, because ignoring it tells the client its freshness requirement was met when it was not. auth_time reports when authentication happened, not when the token was issued: a reused session carries its original instant. Emission follows Entra rather than the maximum — REQUIRED when max_age was requested (OIDC Core 3.1.2.1), otherwise only when the app opts in through optionalClaims, which is where Microsoft’s own reference lists auth_time; it is advertised in claims_supported either way, as real Entra advertises it. ci:sdk-e2e is a third-party verdict, not our own: MSAL Python refuses a result whose ID token lacks auth_time after a max_age request, and refuses again when the auth_time it carries is already stale. The refresh chain carries the authentication it descends from, so a refreshed ID token describes the same sign-in rather than contradicting it, and every rotated successor inherits it. What a refresh deliberately does NOT inherit is the max_age flag: a refresh is not an authorization request carrying one, so the claim appears there only on an optionalClaims opt-in, which is a property of the app and therefore holds for every token it receives
🟢 Real
mTLS / PoP / certificate-bound tokens
—
🔴 Not implemented
JAR by reference (request_uri, RFC 9101)
Real: the signed request object is fetched, verified against the app’s registered keys, and its parameters override the query — and the fetch is SSRF-guarded, reaching only origins the tenant already trusted as this app’s redirect URIs, with no redirects followed and the body size- and time-capped. Deliberate divergence, not parity: Entra answers request_uri_parameter_supported: false, so this capability exists here and not there — code that relies on it will fail against Entra. Recorded in the golden reference and watched by scripts/check_golden_drift.py. ci:jar-metadata-e2e signs the object with PyJWT over a key registered through the admin surface, then proves the guard is load-bearing: an untrusted origin is refused naming the reason, a 302 towards a trusted one is not followed, and the positive case shows the object’s parameters overriding the query. Disabling the allowlist makes the foreign object arrive and apply.
🟢 Real
Inline request parameter
Refused — and not an Entra feature either: the real discovery document leaves request_parameter_supported absent, which per OIDC means false. Implementing it would diverge, not converge. The refusal is delivered the way OIDC Core 3.1.2.6 asks for: error=request_not_supportedon the registered redirect_uri, not as a page, once client_id and redirect_uri are both usable — an unusable one still gets a page, which is the open-redirect guard. The OIDF suite accepts this as “permitted behaviour”; while the refusal was a page it hung waiting for a redirect that never came
🟢 Real
PAR (pushed authorization requests)
Not implemented — and not an Entra feature either: the real discovery document advertises no pushed_authorization_request_endpoint, so this is parity, not a gap
🟢 Real
CAE (continuous access evaluation)
—
🔴 Not implemented
Token lifetime policies
Real and load-bearing: policies/tokenLifetimePolicies plus the $ref assignment onto an application, parsed from Entra’s own JSON-inside-a-string definition with .NET [d.]hh:mm:ss durations. An assigned policy (or isOrganizationDefault) changes the exp of the tokens actually minted, and a definition that would be silently inert is refused
🟢 Real
Claims-mapping policies
Not implemented as a policy resource. Per-app claim shaping is available instead through optionalClaims, groupMembershipClaims, and custom authentication extensions
Real, and diffed against a live tenant: three recorded refusals (a missing object, a malformed object id, and a request with no Authorization header) are compared field by field on every run. Carries the innerError with date / request-id / client-request-id that Entra sends on every error and that SDK logging and Microsoft support correlate on; the emulator omitted it entirely until the recordings found it. The correlation ids in the envelope are the ones in the response headers, and a caller’s own client-request-id is echoed rather than replaced
Full, over the real store. The default projection is diffed against real Entra for users, groups, applications and service principals, on both the entity and the collection read. The emulator no longer returns fields Entra withholds (accountEnabled, userType, externalUserState on a user), which are still reachable with $select; it does not yet return every field Entra does, and the exact gap is recorded and ratcheted in internal/server/differential_test.go so it cannot drift unnoticed in either direction
🟢 Real
Directory writes (users, groups, applications; group membership $ref)
Full CRUD, persisted
🟢 Real
Recycle bin (directory/deletedItems, restore, permanent delete)
Real state machine; the 30-day window is clock-derived, so it’s testable. The type cast answers under both spellings: microsoft.graph.user (Microsoft’s docs) and graph.user (the OpenAPI, and so every Kiota-generated SDK’s DeletedItems.GraphUser). Only the first used to be served, so a generated SDK’s own request builder got a 404 naming a resource “graph.user”. Found by scripts/check_graph_ledger.py; the conformance checker had matched the qualified spelling to deletedItems/{id} and called it fine. Users, groups and applications only; the docs also list devices, service principals and administrative units
🟡 Emulated
OAuth2 permission grants (consent)
Stored and load-bearing: consented scopes are intersected into the token’s scp, honouring AllPrincipals vs per-principal
🟢 Real
Directory roles (roleManagement/directory)
Full CRUD, and assignments really drive wids in tokens
Served for /users/{id} and /me. This is the endpoint the group-overage _claim_sources points at, so it is what makes that payload recoverable rather than a dangling pointer. The directory has no nested groups, so direct membership is the transitive closure; getMemberObjects additionally returns directory-role template ids
Supported (single $filter clause). $select is diffed against real Entra on both surfaces: a projected entity and a projected collection each come back holding the selected fields alone, with no id unless it was selected, and the @odata.context reflects the projection (#users(displayName)/$entity). That corrected a belief the implementation was built on, “Graph always returns id”, which was wrong on both surfaces. $filter is applied before any projection, since it addresses the resource’s properties whether or not they are returned
Real gate behind GRAPH_PERMISSIONS: delegated calls need the scope in scp, app-only calls the role in roles, Directory.* acts as the superset, denials are 403 Authorization_RequestDenied. Off by default — the emulator has always accepted any valid Graph-audience token, so enabling it is opt-in
🟢 Real
Separate servicePrincipal store
An app registration is its own SP; object id and appId are conflated
🟡 Emulated
Custom role definitions
Real CRUD over roleManagement/directory/roleDefinitions: tenant-authored roles list beside the built-ins, are assignable, and deleting one cascades to its assignments. Built-ins are protected from modification, and custom roles are excluded from wids — real Entra emits built-in role template GUIDs there only
🟢 Real
Administrative units
Real CRUD over directory/administrativeUnits plus membership of both users and groups (each returned with its own @odata.type), Public/HiddenMembership visibility, a dangling member refused, and FK-cascade so deleting a unit takes its memberships with it
🟢 Real
Custom security attributes
Real: attribute sets and String/Integer/Boolean definitions (id is Entra’s {set}_{name} composite), assigned onto users with the declared type enforced — an Integer attribute refuses a string and a scalar refuses a collection slot. Returned only on explicit $select, exactly as Graph does
🟢 Real
Graph beta endpoint
v1.0 only
🔴 Not implemented
Sign-in logs (Graph auditLogs/signIns)
Real: served over the flow recorder, so every row is an exchange that actually happened. The recorder now carries the user each exchange resolved, so a delegated row names userId/userPrincipalName while an app-only row is userless (correct, not missing); failures carry their concrete reason and every row has a stable id to de-duplicate on. conditionalAccessStatus is always notApplied — there is no CA engine, by design
Real: every mutation through the Graph write surface is journaled with its activity, category, target resource, and the caller it is attributed to (an app-only caller reports no user, which is correct rather than missing). The emulator’s own admin API is a control surface with no Entra equivalent, so its mutations are deliberately not journaled
Real ceremonies (real assertion verification, real CBOR/COSE); RP derived per-request from the Host, so passkeys work on any origin; drives amr:["fido"]
Real: the new password is scrypt-hashed into the directory, so the old credential immediately stops signing in and the new one works. Omitting newPassword returns a system-generated one in Entra’s passwordResetResponse shape (@odata.context + newPassword, as the docs’ own sample shows), with 202 + Location as Graph answers this long-running operation. The route is methods, not passwordMethods: this row used to name and serve the latter, which Microsoft’s OpenAPI does not have and which every SDK snippet in the docs 404s on, so the real-SDK suite passed while asserting a URL Entra rejects. Found by scripts/check_graph_conformance.py. Known gap:Location points at the method resource, where Graph points at an authentication/operations/{id} resource this emulator does not serve
🟢 Real
Interactive SSPR (verify by email / SMS / security questions at passwordreset.microsoftonline.com)
Not implemented — it is a first-party web flow, not a documented protocol, so emulating it would mean inventing a wire format rather than reproducing one
🔴 Not implemented
SAML 2.0 SP-initiated SSO
Real: an AuthnRequest by either binding, a signed assertion posted back. The assertion is signed with the tenant’s own RSA key under exclusive c14n, carries AudienceRestriction, Recipient, InResponseTo and a five-minute window, and the reply URL is validated against the app’s registered saml-acs endpoints rather than taken from the request. IdP metadata at Entra’s own path publishes the same key as an X.509 certificate
🟢 Real
WS-Federation
Real: the existing FederationMetadata URL grows a WS-Fed RoleDescriptor (PassiveRequestorEndpoint and SecurityTokenServiceEndpoint both /{tid}/wsfed, same signing cert as IDPSSODescriptor). GET|POST /{tid}/wsfed answers wa=wsignin1.0 with the same account picker as OIDC/SAML and POSTs a SAML 2.0 wresult to a registered wsfed-reply. The same route answers wa=wsignout1.0 without minting wresult and 302s to a distinct registered wsfed-reply (SignOutWreply ≠ CallbackPath). Unmodified Microsoft.AspNetCore.Authentication.WsFederation completes sign-in and SignOut (e2e/wsfed). SOAP, /common/wsfed, SAML 1.1, and multi-RP wsignoutcleanup1.0 stay out
🟢 Real
B2C user flows / External ID / CIAM
— stated non-goal
🔴 Not implemented
B2B guest invitations
Real: POST /invitations creates an actual directory user with Entra’s external shape — #EXT# UPN, userType: Guest, externalUserState: PendingAcceptance — and the returned redeem link flips that state to Accepted and redirects to the inviting app. Members keep userType: Member with a null external state
Real token exchange: an external workload presents ITS OWN OIDC token as the client_assertion and the emulator matches a registered issuer/subject/audience trust, then verifies the signature against keys fetched from that issuer’s published JWKS — no secret exists anywhere, which is the whole point. Expiry, wrong subject, wrong audience, forged signature and revoked credential are each refused. Managed through the admin API (/admin/api/apps/{id}/federated-credentials) rather than the Graph federatedIdentityCredentials route
🟢 Real
Graph route for federatedIdentityCredentials
Real CRUD on applications/{id}/federatedIdentityCredentials, writing the same rows the token endpoint matches — a trust created here immediately authenticates an external workload, PATCHing its subject changes who can, and DELETE revokes it. (Applications are addressed by the conflated object id / appId, as on every /applications route)
A PDP port: real engines attach — OpenFGA, SpiceDB, Keto, Permify, Casbin, OPA, Cedar, all exercised in CI. A ~50-line InMemoryPDP ships so the sample runs with nothing attached; it is explicitly not a real engine
Real atomic SQL — not best-effort. ci:concurrency-e2e presents each credential twice, and then eight times at once behind a barrier: a spent device code is refused, and exactly one of eight simultaneous redemptions wins — the check that separates atomic marking from a check-then-mark window, which sequential replay cannot see. Reuse of a rotated refresh token is refused and revokes its successor, so a replayed token does not leave the legitimate holder working. A replayed authorization code likewise revokes the whole refresh chain descended from it (RFC 6749 4.1.2), inherited across rotations, so the replay costs continued access and not just that one exchange. The access token issued from it survives to its expiry, here and in Entra alike: it is a stateless JWT resource servers verify offline against JWKS, which is why Entra ships continuous access evaluation as a separate mechanism rather than revoking centrally.
🟢 Real
Multi-tenant
Multiple tenants exist and are isolated
🟡 Emulated
TLS with a wildcard cert over the emulator’s origins
Real self-signed X.509, regenerated on SAN drift, stable fingerprint otherwise
🟢 Real
CORS on the OIDC surface
Real: discovery, JWKS and instance discovery reflect the caller’s Origin and Vary on it; the token endpoint is gated exactly as Entra gates it — CORS only for an origin the application registered as an spa redirect URI, so an app that works here will not fail against real Entra. Preflight answers with the telemetry headers MSAL.js sends. Without any of this, no browser SPA can authenticate at all
Advertised in discovery, pointing at the emulator’s own origins — a client that reads them is never sent to the real cloud. ci:jar-metadata-e2e FOLLOWS them rather than only reading them: MSAL takes a token and the advertised coordinate answers with this emulator’s seeded directory, and no string anywhere in the document names an Azure host. Known wrinkle: under ORIGIN_MODE=compat (single origin, path-prefixed surfaces) Graph lives under /graph, so the naive https://{msgraph_host}/v1.0/... returns the portal’s HTML with a 200. It stays on the emulator, but it is not directly usable the way real Entra’s graph.microsoft.com is.
🟢 Real
Sovereign clouds (US Gov / China / Germany instance routing)
Single local instance only
🔴 Not implemented
Emulator-only (no Entra equivalent — these exist for testing)
Real clients prove the emulator works; golden references prove its wire
contracts haven’t drifted. Three canonical references — the real Entra OIDC
discovery document, the official Microsoft Graph OpenAPI, and the SCIM 2.0 RFCs
— are committed under e2e/golden/ and diffed against the live emulator on
every push. Several 🟢 rows above name those tests as their witness
(TestGoldenParityOIDCDiscovery, TestGoldenParityGraph,
TestGoldenParitySCIM). See
golden-reference parity for what each asserts
and the documented divergences it reports.
The line is drawn by one question: does this need a policy engine, a risk
model, or a tenant’s compliance posture? Everything above it can be real,
because none of it needs any of the three. A token this emulator signs is a
real token; a passkey it verifies is really verified; an assertion it signs is
signed with the same key and verifies in an unmodified service provider.
MFA, Conditional Access and Identity Protection fail that question and stay
out. Crossing to them would change the project’s character from “the identity
provider your tests run against” to “an identity provider”, which is a
different product with a different duty of care.
SAML was on the wrong side of this line and has moved. It answers the
question the same way a token does: a signed assertion needs no policy engine,
and the emulator already had the key. The list of non-goals is not a fixed
boundary either, which is worth saying plainly — implicit flow, ROPC, OBO,
consent, certificate client auth and Graph writes were all once on it and are
now green rows. What has never moved is the criterion.