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 (one process, one :8443 listener)
┌───────────────┐ ┌────────────────────────────────────┐
│ SPA / Web / │ authority = │ login.entra.localhost STS/OIDC │
│ Daemon / CLI │──login.entra.local──▶ │ graph.entra.localhost Min. Graph │
└───────────────┘ :8443/{tenant} │ portal.entra.localhost API+Portal │
▲ │ Token service (RS256) │
│ validate JWT via JWKS │ SQLite data store │
┌──────┴────────┐ └────────────────────────────────────┘
│ Resource API │◀──────── JWKS ───────────────────────────┘
└───────────────┘
The three surfaces share one HTTPS listener and are routed by Host header. The legacy
localhost/127.0.0.1 origin stays a backward-compat host that serves every route, so existing
setups keep working unchanged.
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-configuration
# The image advertises the localhost compat origin by default (ORIGIN_MODE=compat). To use the
# login./portal./graph. subdomains, add `-e ORIGIN_MODE=subdomains` and map the names on your host.The 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 the advertised issuer/origins default to thelocalhostcompat origin (ORIGIN_MODE=compat— a container can't make*.entra.localhostresolve on the host, and the origin's port followsPORT). 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? The image advertises
https://localhost:<port>by default (ORIGIN_MODE=compat). Switch to the subdomain topology with-e ORIGIN_MODE=subdomains(then map the names on your host), point individual surfaces withLOGIN_ORIGIN/PORTAL_ORIGIN/GRAPH_ORIGIN, or 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).
Deploy Entra Local into Kubernetes with the OCI Helm chart published to GitHub Packages:
<<<<<<< HEAD
helm upgrade --install entra-local \
oci://ghcr.io/cmaneu/entra-local-helm/entra-local \
--version 0.1.0The chart is documented in deployment/helm/README.md. It exposes values for the image, replica count, service, ingress, resources, environment variables, and scheduling options.
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://portal.entra.localhost:8443/ (or compat: https://localhost:8443/)
# Health: https://portal.entra.localhost:8443/health -> {"status":"ok", ...}
# Discovery: https://login.entra.localhost:8443/{tenantId}/v2.0/.well-known/openid-configuration
# Run `npm start -- hosts --apply` once so the *.entra.localhost names resolve (or use localhost).pnpm 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.
npm start and the single-file binary advertise three subdomains of entra.localhost by default,
all served on the one :8443 listener and routed by the Host header (the Docker image defaults
to the localhost compat origin instead — see the note after the table):
| Surface | Origin | Serves |
|---|---|---|
| Login (STS) | https://login.entra.localhost:8443 |
discovery, JWKS, authorize, token, devicecode, logout |
| Portal | https://portal.entra.localhost:8443 |
admin portal SPA + Admin REST API + /health |
| Graph | https://graph.entra.localhost:8443 |
Microsoft Graph + OIDC userinfo |
localhost/127.0.0.1 stays a backward-compat origin that serves every route, so nothing
breaks if you keep using it.
The default is set by ORIGIN_MODE: subdomains for npm start / the SEA binary, and compat
for the Docker image (a container can't make *.entra.localhost resolve on the host, so it advertises
https://localhost:<port>). Flip either way with ORIGIN_MODE=subdomains|compat.
*.entra.localhost does not resolve automatically on every OS (notably Windows), so map the names
to 127.0.0.1 with the built-in hosts command — it prints the plan by default and only writes the
hosts file with --apply (which usually needs elevation):
npm start -- hosts # print the hosts-file plan
npm start -- hosts --apply # write the entries (Administrator / sudo)
npm start -- hosts --remove --apply # remove them againFrom the single-file binary the same subcommand works directly (entra-local hosts --apply). Add
extra apex domains — each gets login./portal./graph. subdomains in the wildcard cert and
the hosts block — via LOCAL_DOMAINS=contoso.test,fabrikam.test, or change the base with
BASE_DOMAIN. Prefer to keep everything on one host? Set ORIGIN_MODE=compat (the Docker image's
default) to collapse every surface onto https://localhost:<port>, set PUBLIC_ORIGIN for a custom
single origin, or just use the localhost compat origin.
The emulator serves HTTPS with an auto-generated, persisted self-signed wildcard certificate
(CN=entra.localhost, OU=Entra Local emulator, SANs for entra.localhost, *.entra.localhost, localhost, 127.0.0.1,
::1, plus any LOCAL_DOMAINS), so the same cert covers every subdomain and the loopback compat
origin. Clients must trust it or relax verification in dev only:
-
Trust it from the portal (works everywhere, including Docker): open the portal Dashboard and use the Trust the certificate section to download the cert and copy a ready-made trust script for Windows, macOS, or Linux. The certificate PEM is also exposed at
/admin/api/certificate/pem(metadata at/admin/api/certificate). -
Trust it with the built-in command (recommended for source/binary): 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).
The portal generates this snippet for each app with the emulator's advertised origins (the login
host for authority/knownAuthorities, the graph host for Graph calls). On the localhost compat
origin the authority is https://localhost:8443/<tenant> and knownAuthorities is localhost:8443.
Run hosts --apply (above) so the subdomains resolve, and 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.
- 🔌 MSAL client configuration — per-platform authority,
knownAuthorities, instance-discovery, and cert-trust settings (including the local-domain origins). - 🔌 MSAL client configuration — per-platform authority,
knownAuthorities, instance-discovery, and cert-trust settings (including the local-domain origins). - 🎫 Token configuration — optional claims & group claims: ID-token vs access-token ownership, supported claims, group overage, and portal steps.
- 🤝 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.