A local, MSAL-compatible emulator of Microsoft Entra ID (formerly Azure AD), for application developers.
Entra Local exposes the same OpenID Connect / OAuth 2.0 endpoints that MSAL talks to —
plus a minimal Microsoft Graph surface and a web portal to register users and applications.
Point your app's authority at the emulator and develop sign-in, token acquisition, and
protected-API calls offline, with no cloud tenant, and no shared-tenant clutter.
Caution
Local development tool only — intentionally insecure, and a partial emulation. Entra Local
trades security for developer ergonomics: an open, unauthenticated admin portal and API,
self-signed certificates, publicly known seeded users and secrets, and signing keys
stored unencrypted on disk. It emulates only a small, fixed slice of Entra ID. Run it on
localhost only — never put real users, passwords, or secrets into it, and never expose it to
an untrusted network or the public internet. See
What it emulates and
Security & limitations.
Testing against a real Entra ID tenant is slow and heavyweight: it needs cloud access and admin consent, clutters shared tenants with throwaway app registrations, and can't run offline or deterministically in CI. Entra Local is a drop-in identity provider that speaks the same protocols, so your MSAL-based app works against it with only configuration changes.
- 🔐 MSAL-compatible OIDC/OAuth2 endpoints — discovery, JWKS, authorize, token, device code, logout, userinfo.
- 🎫 Supported flows — Authorization Code + PKCE, Client Credentials, Refresh Token, Device Code.
- 🪪 Real RS256-signed JWTs — ID and access tokens validatable against a working JWKS endpoint.
- 👥 Minimal Microsoft Graph — read
/me,/users,/groups. - 🖥️ Web portal — register users, groups, and app registrations; copy ready-to-paste MSAL config snippets.
- 💾 SQLite persistence — state survives restarts; deterministic seed data for CI.
- 🔒 HTTPS by default — auto-generated, persisted self-signed certificate.
- 📦 Run anywhere —
npm start, a single executable, or a Docker container.
Entra Local deliberately emulates a small, well-defined slice of Entra ID: the protocol surface MSAL needs for the most common developer scenarios. Treat anything not listed under Supported as unsupported — if your app depends on it, validate against a real tenant.
Developer scenarios
- SPA — Authorization Code + PKCE (public client).
- Web app — Authorization Code with a confidential client secret.
- Daemon / service — Client Credentials (app-only token with app roles).
- CLI / device — Device Code flow (RFC 8628).
- Token refresh — rotating refresh tokens.
- Protected API + minimal Graph — call
/me,/users,/groupswith the access token.
Protocol surface
- OIDC discovery (
.well-known/openid-configuration) and JWKS. authorize,token,devicecode,userinfo, and front-channellogout.- Real RS256-signed ID and access tokens, verifiable against the JWKS endpoint.
- Minimal Microsoft Graph: read-only
/me,/users,/groups. - Admin REST API + web portal to manage users, groups, and app registrations.
- Multiple / real directories — a single fixed tenant only.
- Implicit flow and ROPC (resource-owner password grant).
- On-Behalf-Of (OBO), SAML 2.0, and WS-Federation.
- MFA, Conditional Access, Identity Protection, and consent prompts (apps are auto-consented).
- Certificate /
private_key_jwtclient authentication (client secrets only). - Full Microsoft Graph (writes and most resources) and advanced claims/token policies.
See the roadmap for what may land in later iterations.
Your app (MSAL) Entra Local (single process)
┌───────────────┐ authority = ┌──────────────────────────────┐
│ SPA / Web / │ https://localhost: │ OIDC / OAuth2 endpoints │
│ Daemon / CLI │──────8443/{tenant}──▶│ Minimal Graph (/me /users) │
└───────────────┘ │ Admin REST API + Portal │
▲ │ Token service (RS256) │
│ validate JWT via JWKS │ SQLite data store │
┌──────┴────────┐ └──────────────────────────────┘
│ Resource API │◀──────── JWKS ───────────────────┘
└───────────────┘
Entra Local runs several ways with identical behavior and one config/data model — the only
difference is where the persisted data/ directory lives (a Docker volume vs. the host working
directory). The quickest start is the prebuilt Docker image; you can also run from source or
as a single-file binary. Every target serves HTTPS on 8443 by default, auto-generates and
persists a self-signed certificate, and seeds a deterministic directory on first boot.
Pull and run the prebuilt image from the GitHub Container Registry — no clone, no toolchain:
docker pull ghcr.io/cmaneu/entra-local
docker run -p 8443:8443 -v entra-local-data:/app/data ghcr.io/cmaneu/entra-local
# Portal: https://localhost:8443/
# Health: https://localhost:8443/health -> {"status":"ok", ...}
# Discovery: https://localhost:8443/{tenantId}/v2.0/.well-known/openid-configurationThe image is a hardened multi-stage build (Node 24 base for the built-in node:sqlite
driver), runs as a non-root user, and ships only the runtime (compiled server + prebuilt
portal asset + production dependencies — no React/Vite/test toolchain). It declares a
HEALTHCHECK that polls /health over TLS and a VOLUME at /app/data. :latest tracks the
newest full release; pin a specific version with ghcr.io/cmaneu/entra-local:<version>.
-
Persistence: mount a named volume at
/app/dataso the SQLite DB and the auto-generated cert (stable fingerprint) survivedocker stop/docker startand upgrades. -
Port mapping: the container binds
HOST=0.0.0.0internally (so the published port is reachable from the host) while issuer/origin still default tohttps://localhost:8443. Map it with-p 8443:8443(or-p <hostPort>:8443). -
Config passthrough: every config key is read from the environment — for example:
docker run -p 9000:9000 \ -e PORT=9000 \ -e TENANT_ID=11111111-1111-1111-1111-111111111111 \ -e REQUIRE_PASSWORD=true \ -v entra-local-data:/app/data ghcr.io/cmaneu/entra-local
-
Fronted differently? If clients reach the emulator at something other than
https://localhost:8443(a different host/port, or behind a proxy), setPUBLIC_ORIGIN(and optionallyISSUER) so discovery/JWKS/token URLs and the tokenissmatch what clients actually use — e.g.-e PUBLIC_ORIGIN=https://entra.localtest.me:8443.
Drop this into a docker-compose.yml next to your project and run docker compose up:
services:
entra-local:
image: ghcr.io/cmaneu/entra-local:latest
ports:
- '8443:8443'
volumes:
- entra-local-data:/app/data
# Optional: override any config key via the environment.
# environment:
# PUBLIC_ORIGIN: https://entra.localtest.me:8443
# TENANT_ID: 11111111-1111-1111-1111-111111111111
# REQUIRE_PASSWORD: 'true'
volumes:
entra-local-data:
⚠️ The container binds0.0.0.0for host port mapping. This is fine for an isolated local container, but Entra Local is a dev tool with seeded secrets and an open admin API — never publish it on an untrusted network or the public internet.
🛠️ Contributors can build the image locally instead of pulling it:
docker build -t entra-local .(orpnpm run docker:build && pnpm run docker:run).
pnpm install # install dependencies
pnpm run build # compile the server (dist/) + build the admin portal (single-file)
pnpm start # run the built server: node dist/index.js
# Portal: https://localhost:8443/
# Health: https://localhost:8443/health -> {"status":"ok", ...}
# Discovery: https://localhost:8443/{tenantId}/v2.0/.well-known/openid-configurationpnpm start runs the built server, so run pnpm run build first (a guard aborts with a
clear message if dist/ or the portal bundle is missing). State (the SQLite DB at DB_PATH
and the TLS cert/key under TLS_CERT_DIR) persists under ./data/ and survives restarts.
For an auto-reloading dev loop instead, use pnpm run dev.
Build a self-contained native executable that boots the full emulator with no Node
install, no npm install, and no external files — the compiled server, all production
dependencies, the admin portal, and the version metadata are bundled/embedded into one binary
(built with Node Single Executable Applications).
Persistence uses the built-in node:sqlite, so there are no native bindings to ship.
pnpm run build # compile the server + the single-file portal (prerequisite)
pnpm run build:sea # bundle (esbuild) -> embed assets -> inject the SEA blob (postject)
# -> dist-sea/entra-local (Linux/macOS)
# -> dist-sea/entra-local.exe (Windows)Run it like any other binary; it reads the same env/config keys as pnpm start and writes
the same data/ layout (SQLite DB + auto-generated TLS cert) relative to its working
directory:
# Linux/macOS
./dist-sea/entra-local
# Windows (PowerShell)
.\dist-sea\entra-local.exe
# override config via env, e.g.:
# PORT=9000 TENANT_ID=11111111-1111-1111-1111-111111111111 ./dist-sea/entra-local- Data & cert: like the other targets, the binary creates
./data/entra-local.dband a persisted self-signed cert under./data/tls/on first run (override withDB_PATH/TLS_CERT_DIR). - Supported platforms: Windows, Linux, and macOS (x64/arm64). SEA does not
cross-compile — build the binary on the OS you intend to run it on. The binary embeds the
Node runtime used to build it (must be ≥ 22.5 for
node:sqlite). - Unsigned (dev only): the produced binary is not code-signed, so Windows SmartScreen /
macOS Gatekeeper may warn on first run (and
postjectprints a benign "signature seems corrupted" notice on Windows because it invalidates Node's original signature). This is a developer tool — do not redistribute the unsigned binary as a trusted release. - Smoke-test it:
pnpm run test:seabuilds the binary and runs an automated check that it boots over HTTPS, serves/health(with the embedded version), the portal at/, and OIDC discovery. The same test also runs (gated on the binary existing) inpnpm test.
Publishing a GitHub Release runs the Release workflow, which
re-runs the full lint/typecheck/build/test gate and then publishes the distributable artifacts:
-
Single-file binaries — built per OS (Node SEA does not cross-compile), smoke-tested, and attached to the Release as
entra-local-linux-x64,entra-local-windows-x64.exe, andentra-local-macos-arm64. -
Docker image — pushed to the GitHub Container Registry (part of GitHub Packages) at
ghcr.io/cmaneu/entra-local, tagged with the release version (andlatestfor a full, non-prerelease Release):docker run -p 8443:8443 -v entra-local-data:/app/data ghcr.io/cmaneu/entra-local:latest
The Release tag is stamped into the artifacts at build time, so the running build reports that
exact version at /health — and the portal surfaces it in the top-bar health chip and the
Dashboard "Version" card.
The emulator serves HTTPS with an auto-generated, persisted self-signed certificate
(CN=localhost, SANs for localhost/127.0.0.1/::1), so clients must trust it or relax
verification in dev only:
-
Trust it with the built-in command (recommended): the emulator can tell you exactly what to run for your OS — or run it for you:
pnpm start -- trust # print the platform trust command + NODE_EXTRA_CA_CERTS hint pnpm start -- trust --apply # actually trust it (may prompt for elevation) pnpm start -- untrust --apply # remove it from the trust store pnpm start -- cert-path # print the path to cert.pem pnpm start -- show-cert # print the path + SHA-256 fingerprint
From the single-file binary the same subcommands work directly (
entra-local trust,entra-local cert-path, …). By defaulttrustonly prints the command;--applyexecutes it. -
Trust it manually: import the cert into your OS/browser trust store. From source it is
./data/tls/cert.pem; from a container, copy it out withdocker cp <container>:/app/data/tls/cert.pem ./cert.pem, then runtrust --apply(or your OS's import step) on the host — a container's trust store is not your host's. -
Bypass it (dev only): point your client's CA at that
cert.pem, or setNODE_TLS_REJECT_UNAUTHORIZED=0for a Node client /curl --insecurefor quick checks. Never disable verification for anything but local development. -
Bring your own cert: set
TLS_CERT+TLS_KEYto use a custom pair (both or neither).
Trust the emulator's self-signed certificate (or relax cert validation in dev) so MSAL can fetch the discovery document and JWKS over HTTPS.
The toolchain and project layout are established by roadmap feature #1
(spec). Node 22.13+ and
pnpm ≥ 9 are required (the persistence layer uses the built-in
node:sqlite).
pnpm install # install dependencies
pnpm run dev # run the server with reload (tsx watch)
pnpm run build # type-check + emit server to dist/ (+ portal placeholder)
pnpm run typecheck # tsc --noEmit across server + tests
pnpm run lint # eslint + prettier --check
pnpm test # unit + integration tests (vitest, in-process, deterministic)
pnpm run test:e2e # real-MSAL end-to-end suite (starts a real HTTPS server)
pnpm start # run the built server (node dist/index.js)Configuration is loaded from environment variables → entra-local.config.json → built-in
defaults (highest precedence first) and validated on startup; see
.env.example and
entra-local.config.example.json for the full reference.
Invalid config aborts startup with a non-zero exit naming the offending key.
Source layout: src/ (server) with config/ (zod validation), tls/ (cert generation),
http/ (routing, path map, error handling); test/ (unit/, integration/, e2e/,
helpers/); portal/ (admin portal, built in #12). Runtime state (SQLite DB + the persisted
self-signed cert) lives under data/ (gitignored).
Browser e2e (
@azure/msal-browservia Playwright) is opt-in behindE2E_BROWSER=1, sopnpm run test:e2estays green without a browser download. Set it to exercise the real interactive sign-in loop end-to-end in a headless browser.
- 🗺️ Roadmap — iterations, MVP cut, dependencies, and deferred work.
- 📋 Global specification — goals, architecture, API surface, token design, data model, configuration, deployment, and acceptance criteria.
- 🤝 Contributing — how to propose changes (open an issue first) and the project's governance.
- 🧠 Decisions / Conventions — project memory.
Iteration 1 (MVP) is complete and verified end-to-end against real MSAL clients:
Authorization Code + PKCE sign-in, Client Credentials, Refresh Token, Device Code
(RFC 8628), UserInfo/Logout, minimal Graph, the admin REST API + web portal, and all three
run targets (npm start, Docker, and the single-file binary). Remaining work tracked in the
roadmap: optional password-login enforcement, MSAL sample apps
(JS / React / Node CLI / .NET / Python), and a public developer-documentation pass.
Multi-tenant, OBO, broader Graph, and certificate-based client auth remain deferred.
Entra Local is a development tool, not a secure identity provider. It is insecure by design so it stays easy to run and reset:
- No authentication on the admin surface — the portal and the
/admin/api/...REST API are fully open to anyone who can reach the port. - Publicly known, seeded credentials — fixed users, passwords, and app secrets ship in the source (listed below) so flows are reproducible in CI.
- Self-signed TLS — a certificate you must explicitly trust or bypass, in dev only.
- Signing keys stored unencrypted on disk, to keep a stable
kidacross restarts. - No MFA, Conditional Access, consent, rate limiting, or audit — the sign-in page even warns end users not to type a real password.
- A single, auto-consented directory — every registered app is implicitly trusted.
Run it on localhost only. Never point a real application or real secrets at it, and never
expose it beyond your machine or an isolated local container.
It ships a deterministic, fixed-GUID seed directory so flows are reproducible in CI:
- Default tenant
11111111-1111-1111-1111-111111111111(Entra Local). - Users
alice@entralocal.devandbob@entralocal.dev(both members of theEngineeringgroup), with the known dev passwordPassword1!. - A public SPA app (
cccccccc-…-0001, redirecthttps://localhost:3000, scopeaccess_as_user) and a confidential daemon app (cccccccc-…-0002, app roleTasks.Read.All) whose client secret is the known dev valuedaemon-app-secret.
These credentials are intentionally public and dev-only. Passwords and app secrets are stored
hashed (scrypt) at rest, and refresh tokens and device codes are stored hashed (SHA-256) — but
signing keys are persisted unencrypted for a stable kid. Reset to a clean seed at any time from
the portal or the admin REST API (/admin/api/...).
Entra Local is an independent developer tool and is not affiliated with, endorsed by, or supported by Microsoft. "Microsoft Entra ID", "Azure AD", "Microsoft Graph", and "MSAL" are trademarks of Microsoft. This project emulates publicly documented protocol behavior for local development and testing only.
MIT © Christopher Maneu & Entra Local contributors.