Entra Emulator

v0.9.0 WS-Federation sign-out, and the last two rows without an outside witness →

Develop against Entra ID sign-in without a tenant.

The OIDC and OAuth 2.0 v2.0 endpoints MSAL talks to, SAML 2.0 and WS-Federation, passkeys, Microsoft Graph and SCIM provisioning, in a single Go binary on your laptop. Point an unmodified MSAL client at it by changing the authority and nothing else.

Real RS256 signatures, real WebAuthn ceremonies, real directory state that survives a restart. Not a stub that returns a fixed token.

Every capability claim on this page names the CI job that proves it, and things it does not do are listed just as plainly.

Terminal recording: OIDC discovery against the local emulator, a client-credentials token request, and the decoded Entra v2.0 claims of the resulting JWT.
parity claims, all witnessed
proved by an external client in CI
6languages driving it
~13MBdistroless image, no cgo
rows it does not implement

The problem this solves

Identity is the first thing an application does and the last thing anyone can test locally. Three costs follow, and they compound.

A tenant per developer

Sign-in work needs app registrations, redirect URIs, users, groups, secrets and consent. Getting those provisioned is a ticket, and changing them is another one. So sign-in is the part of the stack nobody refactors.

Fixed GUIDs, seeded on first run, reset in one call

CI that needs the internet, and credentials

A test that acquires a real token needs a real secret in the runner, a real tenant reachable from it, and tolerance for someone else's rate limits. Most teams answer by mocking the token, which stops testing the thing that breaks.

One binary, no network, no secret worth stealing

Tests that pass because you wrote both sides

A hand-rolled fake IdP agrees with the client you wrote against it. It agrees right up to the day a real library refuses the token, and your suite never had an opinion about that.

Microsoft's own clients are the oracle here, not ours

How it fits together

One HTTPS listener, routed by Host. Clients on the left are unmodified and know nothing about this project; the emulators on the right verify the tokens it issues against its published JWKS.

MSAL clients node · go · python .NET · java · browser Federation stacks node-saml · WS-Fed middleware Browsers Chromium + CDP virtual authenticator SCIM & CLI az · scim2-tester entra-emulator one Go binary · one listener login.entra.localhost oidc · saml2 · wsfed · devicecode graph.entra.localhost users · groups · scim portal.entra.localhost admin REST · svelte portal SQLite (pure Go) · RSA signing key Your API validates against JWKS kid = RFC 7638 thumbprint The emulator family key vault · ARM · fabric APIM · databricks every one of them verifies tokens this issuer mints Go test process embedded, no port, no server
Subdomains need hosts entries; ORIGIN_MODE=compat serves everything from https://localhost:8443 instead, which is what CI uses.

Seven defects our own tests could not see

A third of this project's parity claims were once witnessed by our own Go tests: our client, on both ends of our own implementation. Replacing that client with Microsoft's found seven interop defects and one data-loss bug. Every one of them had passing tests.

What was greenWhat a real client found
private_key_jwt MSAL signs client assertions with PS256; the emulator verified only RS256. Five Go tests passed while no Microsoft Go client could authenticate with a certificate at all.
Resource owner password MSAL Go probes /common/UserRealm before it will send credentials, and gives up on a non-200. The route did not exist, so ROPC was unreachable.
Custom API scopes Clients send api://<app>/<scope>; the emulator echoed the short name, and the narrowing check then compared two different vocabularies.
Group overage The overage payload points a client at getMemberObjects to recover groups the token could not carry. That route did not exist, and the URL was built without the compat prefix. Dead twice over.
SCIM discovery Every meta.location pointed at routes that were never mounted, and /Schemas carried no attributes, so no client could learn that userName is required. An independent RFC checker scored it 3 passed, 31 errored against a row already graded green.
SCIM PATCH Silently did nothing for name, emails and externalId; remove was a no-op returning 200 OK. The worst failure mode, because it looks like success.
Incremental provisioning The watermark is Unix seconds compared with <=, so a change committed inside a sync's own second was skipped by that sync and every one after it. Not delayed. Lost.
This is why the witness tier is recorded per claim, not averaged.

A claim is credited once, by its strongest evidence: a CI job driving a packaged external client beats a Microsoft library linked in-process, which beats our own Go tests. Counting citations instead would flatter every row, because one claim can carry four witnesses. The parity map names the witness for each, and CI fails when one goes missing.

Who drives it in CI

Unmodified, as published, from the package manager. Two knobs make a custom authority work in every Microsoft SDK: disable instance discovery, and trust the certificate.

Microsoft's own clients witnessed

@azure/msal-node, @azure/msal-browser, the Microsoft Graph JavaScript client, MSAL Go and azidentity, MSAL Python, MSAL.NET with the Wilson token-validation stack, MSAL4J, and the az CLI registered as a custom cloud.

python3 e2e/run.py

Independent conformance witnessed

node-saml as an unmodified service provider, Microsoft.AspNetCore.Authentication.WsFederation as unmodified middleware, and scim2-tester, an RFC 7643/7644 checker that knows nothing about this project.

A checker we did not write cannot share our assumptions

Microsoft's reference server witnessed

Outbound SCIM provisioning is proved against AzureAD/SCIMReferenceCode, receiving the full Entra sequence: filter probe, create, externalId correlation, and active:false deprovision.

Both directions of SCIM, neither of them ours

Real browsers witnessed

Chromium completes the redirect flow, the RP-initiated logout, and the implicit and hybrid front-channel responses. Passkeys register and assert through a CDP virtual authenticator, yielding amr: ["fido"].

The front-channel logout iframe is really fetched, not just present

Real devices nightly, not a PR gate

An Android emulator and an iOS simulator run the device-code flow end to end from a Flutter app. No official MSAL exists for Dart, so the automated path uses Dart http and the flutter_appauth screen is manual.

.github/workflows/flutter-e2e.yml

The rest of the family witnessed

Key Vault, ARM, Fabric, APIM and Databricks emulators all validate tokens this issuer mints. A change to the claim shape here fails there, which is a consumer test no amount of local testing substitutes for.

github.com/calvinchengx/azure-emulators

What it does

Flows

Authorization Code + PKCE (S256 and plain), Client Credentials with .default and app-role auto-grant, rotating Refresh Tokens with family revocation on reuse, Device Code (RFC 8628) with its human approval page, on-behalf-of, implicit and hybrid, front-channel logout, and UserInfo.

Federation

SAML 2.0 SP-initiated SSO at /{tid}/saml2 and WS-Federation passive sign-in and sign-out at /{tid}/wsfed, both advertised on the same FederationMetadata URL real Entra uses. Repointing an SP means changing the host.

Tokens

Real RS256 JWTs with Entra v2.0 claim shapes: tid, oid, scp/roles, pairwise sub, ver: "2.0", client_info. Optional and group claims per app registration, including the Entra-style overage payload.

Sign-in methods

Account picker and password giving amr: ["pwd"], and passkeys (FIDO2/WebAuthn) giving amr: ["fido"], with the relying party built per request from the Host header so passkeys work on any origin.

Directory

A stateful Microsoft Graph: users, groups and membership, app registrations, role assignments, administrative units, invitations, audit and sign-in logs, and soft-delete through the recycle bin. SCIM 2.0 in both directions.

Embeddable in Go tests

The emulator package runs the whole thing in-process, with no external server and no fixed port, so a Go test can point MSAL Go or azidentity straight at it and get a token.

What it will not do

The line is drawn at the policy engine. Everything below is what separates a development-loop emulator from an identity provider, and none of it is planned.

CapabilityStatusWhy
Conditional Accessnot implementedPolicies, named locations and authentication strength are the product, not a protocol surface. Emulating them convincingly would mislead.
MFA and step-upnot implementedNo second factor exists to challenge. amr reports honestly what did happen: pwd or fido.
Identity Protection, risky usersnot implementedRisk scoring has no meaning against seeded users on a laptop.
PIM, entitlement management, access reviewsnot implementedGovernance surfaces, not sign-in surfaces.
B2C user flows, External ID, CIAMnot implementedA separate product with a separate protocol surface.
Cross-tenant access policiesnot implementedPartner settings and inbound/outbound trust need more than one real tenant to mean anything.
Device registration, Intune compliancenot implementedDevice-bound tokens require a device identity story this does not have.
Sovereign cloudsnot implementedUS Gov, China and Germany instance routing. The discovery document names this emulator's own coordinates and nothing else.
Graph betanot implementedv1.0 only. A partial beta would be worse than its absence.
mTLS, PoP, certificate-bound tokensnot implementedBearer tokens only.
Multi-tenantemulatedAdditional tenants exist in the store and route correctly, but there is no cross-tenant consent or guest-object model behind them.
Provisioning scheduleremulatedSCIM sync runs on demand. Entra's ~40-minute cycle is not simulated; nothing waits.
Fabric control planebring your own engineWorkspace identity provisioning is real when fabric-emulator is attached, and a toy stands in otherwise.
Externalized authorizationbring your own engineRelationship-based authorization is delegated to a real PDP: OpenFGA, OPA, Cedar, SpiceDB, Keto, Casbin or Permify.
Intentionally insecure, and not a security boundary.

Open admin API, publicly known seeded users and secrets, self-signed TLS, and a signing key stored unencrypted on disk. It exists so a laptop can issue tokens that look real enough to develop against. Run it on localhost. Never point real users or real secrets at it.

Get it

A ~13 MB distroless image with a built-in healthcheck, or a static binary for six platform pairs. Pure Go, no cgo, no runtime dependency.

docker run -p 8443:8443 -v entra-emulator-data:/app/data \
  ghcr.io/calvinchengx/entra-emulator:latest

brew install calvinchengx/tap/entra-emulator     # macOS and Linux
winget install calvinchengx.entra-emulator       # Windows
go install github.com/calvinchengx/entra-emulator/cmd/entra-emulator@latest

The image defaults to ORIGIN_MODE=compat and binds 0.0.0.0; mount a volume at /app/data to keep the store, the certificate and the signing key stable across restarts. Every install method is in installation.

Run it

Three verbs, the same on Linux, macOS and Windows. run needs no container runtime at all.

make doctor   # toolchain check, run this first
make run      # build and serve at https://localhost:8443
make status   # is it actually serving? probes discovery, JWKS, a real token mint

Then point a client at it. Only the authority changes; knownAuthorities is what tells MSAL to skip instance discovery against Microsoft's cloud metadata.

{
  "auth": {
    "clientId": "189c7070-78a3-4c13-aa18-20a2ca5755ca",
    "authority": "https://localhost:8443/6f89cf12-978b-4d23-ac18-9ef0c127cf87",
    "knownAuthorities": ["localhost:8443"],
    "redirectUri": "https://localhost:3000"
  }
}

The seed is deterministic and its GUIDs are fixed, so a CI fixture written today still resolves next year: alice@entraemulator.dev and bob@entraemulator.dev with password Password1!, a public SPA, and a confidential daemon holding the Tasks.Read.All app role. Full detail in data model and seed.