Skip to content

Managed Identity

Compatible with the azure-identity ManagedIdentityCredential (Java, Python, Node.js), the Microsoft.ManagedIdentity ARM management plane, and any HTTP client. Covers user-assigned identities (with federated identity credentials) and the IMDS token endpoint that applications use to acquire tokens without secrets.

HTTP-only: no Docker. ARM CRUD state follows storage.mode; IMDS tokens are minted with the same RSA signing key as the Entra ID emulation, so they verify against the emulator JWKS.


Features

  • User-assigned identities: CreateOrUpdate, Get, Update (tags), Delete, List (by resource group and by subscription); principalId / clientId / tenantId are server-generated GUIDs that stay stable across updates (msi spec 2024-11-30)
  • Identity references: a PUT or PATCH of any resource whose identity.userAssignedIdentities names an identity that doesn't exist answers 400 FailedIdentityOperation and stores nothing; a null entry, which removes an identity, needs no existing identity
  • Federated identity credentials: CreateOrUpdate, Get, Delete, List with issuer / subject / audiences (as used by azurerm_federated_identity_credential)
  • System-assigned identity read: GET {scope}/providers/Microsoft.ManagedIdentity/identities/default returns deterministic per-scope GUIDs
  • IMDS token endpoint: GET /metadata/identity/oauth2/token (imds spec 2023-07-01): requires the Metadata: true header, accepts resource plus an optional client_id / object_id / msi_res_id selector, and returns the all-string IMDS response shape. Any api-version is accepted (SDKs send 2018-02-01)
  • App Service token endpoint: GET /msi/token (the IDENTITY_ENDPOINT / IDENTITY_HEADER protocol used by Go azidentity and Rust azure_identity in App Service mode): requires the X-IDENTITY-HEADER header and resource, accepts client_id / object_id / mi_res_id / msi_res_id selectors, and returns an all-string response (including a quoted expires_on)
  • Verifiable tokens: v1.0 JWTs (appid, oid, idtyp=app, and xms_mirid naming the identity's ARM id) signed by the Entra key; validate against GET /common/discovery/v2.0/keys

Endpoints

PUT    /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}
GET    /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}
PATCH  /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}
DELETE /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}
GET    /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities
GET    /subscriptions/{sub}/providers/Microsoft.ManagedIdentity/userAssignedIdentities

PUT    .../userAssignedIdentities/{name}/federatedIdentityCredentials/{ficName}
GET    .../userAssignedIdentities/{name}/federatedIdentityCredentials/{ficName}
DELETE .../userAssignedIdentities/{name}/federatedIdentityCredentials/{ficName}
GET    .../userAssignedIdentities/{name}/federatedIdentityCredentials

GET    /{scope}/providers/Microsoft.ManagedIdentity/identities/default

GET    /metadata/identity/oauth2/token?resource={resource}[&client_id=...]   # header: Metadata: true

GET    /msi/token?resource={resource}[&client_id=...]                        # header: X-IDENTITY-HEADER: {secret}

Quickstart

1: Create a user-assigned identity

curl -s -X PUT \
  "http://localhost:4577/subscriptions/my-sub/resourceGroups/my-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity?api-version=2024-11-30" \
  -H "Content-Type: application/json" \
  -d '{"location":"eastus"}'
{
  "name": "my-identity",
  "type": "Microsoft.ManagedIdentity/userAssignedIdentities",
  "properties": {
    "tenantId": "00000000-0000-0000-0000-000000000002",
    "principalId": "e3b0c442-…",
    "clientId": "9f86d081-…"
  }
}

2: Acquire a token via IMDS (raw HTTP)

curl -s -H "Metadata: true" \
  "http://localhost:4577/metadata/identity/oauth2/token?resource=https://management.azure.com/&api-version=2018-02-01&client_id={clientId}"

Omit client_id for a system-assigned token. All response values are strings, per the real IMDS contract:

{
  "access_token": "eyJ0…",
  "client_id": "9f86d081-…",
  "expires_in": "3599",
  "expires_on": "1767225599",
  "ext_expires_in": "3599",
  "not_before": "1767221999",
  "resource": "https://management.azure.com/",
  "token_type": "Bearer"
}

3: Use ManagedIdentityCredential from the SDKs

The azure-identity SDKs target http://169.254.169.254 by default. Point them at the emulator with AZURE_POD_IDENTITY_AUTHORITY_HOST (honored by the Java, Python, and Node.js SDKs):

export AZURE_POD_IDENTITY_AUTHORITY_HOST=http://localhost:4577
from azure.identity import ManagedIdentityCredential

credential = ManagedIdentityCredential(client_id="{clientId}")  # or no args for system-assigned
token = credential.get_token("https://management.azure.com/.default")
ManagedIdentityCredential credential = new ManagedIdentityCredentialBuilder()
        .clientId("{clientId}")
        .build();
AccessToken token = credential
        .getToken(new TokenRequestContext().addScopes("https://management.azure.com/.default"))
        .block();
const { ManagedIdentityCredential } = require("@azure/identity");

const credential = new ManagedIdentityCredential("{clientId}");
const token = await credential.getToken("https://management.azure.com/.default");

4: Acquire a token via App Service Managed Identity (raw HTTP)

The Go (azidentity) and Rust (azure_identity) credential chains use a different protocol in App Service mode. They read IDENTITY_ENDPOINT and IDENTITY_HEADER, then GET the endpoint with the X-IDENTITY-HEADER header. floci-az serves that protocol natively at /msi/token:

curl -s -H "X-IDENTITY-HEADER: floci-az-msi-secret" \
  "http://localhost:4577/msi/token?resource=https://vault.azure.net&api-version=2019-08-01"

The consumer, not floci-az, sets IDENTITY_ENDPOINT and IDENTITY_HEADER. For the defaults above:

export IDENTITY_ENDPOINT=http://localhost:4577/msi/token
export IDENTITY_HEADER=floci-az-msi-secret

The request contract:

  • X-IDENTITY-HEADER (required) must equal the configured secret (app-service-header-secret).
  • resource (required) is echoed back in the response.
  • api-version is accepted but any value is allowed; the canonical value is 2019-08-01.
  • Selectors are optional and mutually exclusive: client_id, object_id, and the ARM-resource-id selector which has two spellings (mi_res_id and msi_res_id; both are accepted, and supplying both is treated as the single resource-id selector). Omitting all selectors yields a system-assigned token.

The response is all strings, and expires_on is a quoted epoch-seconds string (never a JSON number, never a datetime): the Rust azure_identity SDK parses it with a custom deserializer that accepts only a JSON string of integer epoch-seconds.

{
  "access_token": "eyJ0…",
  "client_id": "9f86d081-…",
  "expires_in": "3599",
  "expires_on": "1767225599",
  "resource": "https://vault.azure.net",
  "token_type": "Bearer"
}

Configuration

floci-az:
  services:
    managed-identity:
      enabled: true
      system-assigned-scope: subscriptions/${floci-az.services.arm.default-subscription-id}
      app-service-enabled: true
      app-service-path: msi/token
      app-service-header-secret: floci-az-msi-secret
Property Env var Default Description
enabled FLOCI_AZ_SERVICES_MANAGED_IDENTITY_ENABLED true Enables the ARM provider and both token endpoints
system-assigned-scope FLOCI_AZ_SERVICES_MANAGED_IDENTITY_SYSTEM_ASSIGNED_SCOPE subscriptions/ + the ARM default-subscription-id ARM scope that seeds the system-assigned IMDS identity's principalId/clientId. Set it to your resource's scope (e.g. subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachines/{vm}) so IMDS tokens match GET {scope}/.../identities/default reads
app-service-enabled FLOCI_AZ_SERVICES_MANAGED_IDENTITY_APP_SERVICE_ENABLED true Enables the App Service Managed Identity token endpoint at app-service-path
app-service-path FLOCI_AZ_SERVICES_MANAGED_IDENTITY_APP_SERVICE_PATH msi/token Data-plane token path for App Service Managed Identity, stored with no leading slash
app-service-header-secret FLOCI_AZ_SERVICES_MANAGED_IDENTITY_APP_SERVICE_HEADER_SECRET floci-az-msi-secret Secret value the X-IDENTITY-HEADER header must carry

Token tenant, issuer, and lifetime follow the Entra ID settings (floci-az.services.entra.*).


Intentional deviations

  • clientSecretUrl is synthetic: real Azure returns a Key Vault-backed credential renewal URL for identities/default; the emulator returns a placeholder URL under the emulator base.
  • Any api-version is accepted on both planes; real IMDS rejects unknown versions.
  • isolationScope is not modeled on user-assigned identities.
  • Federated identity credentials are CRUD-only: no token-exchange semantics.
  • Unknown identities return 400 {"error":"invalid_request","error_description":"Identity not found"}, matching real IMDS behavior for unassigned user identities.
  • The system-assigned IMDS identity is not tied to a caller resource: on real Azure, IMDS runs on the resource and returns that resource's own identity. The emulator is not attached to a resource, so system-assigned tokens are seeded from the configured system-assigned-scope (default: the default subscription). identities/default reads for other scopes return different (deterministic) GUIDs; set system-assigned-scope to your resource's scope when your code compares an ARM-read principalId against the token's oid.
  • Identity state follows storage.mode: identities and federated credentials are kept in the managedidentity store, so under a persistent mode they survive a restart with the same client and principal ids. An identity addressed in another casing (resourcegroups/RG, MyIdentity) is the same identity, and responses keep the casing of the request that created it.
  • Resource-group deletion does not cascade: deleting a resource group leaves its identities behind (consistent with the other ARM resources in the emulator). Identities do appear in GET .../resourceGroups/{rg}/resources, so prevent_deletion_if_contains_resources works.