New Livestream: OWASP Top 10 2025 Explained for .NET Developers.

Register Now!

Bridge Azure Workload Identity to Client Identity with Dynamic Client Registration

Maarten Balliauw
Two blue circles

With a workload identity from Microsoft Entra Workload ID, your microservice running in Azure already has a secure identity out of the box. Managed identities provided by Microsoft Entra ID allow your application to prove its identity without storing a single secret or credential in configuration files.

The workload then has to call another service in your OAuth 2.0 and OpenID Connect-secured microservices architecture, and suddenly it needs something else entirely: an OAuth client_id. Someone typically creates a client_id and secret in configuration, and pastes it into a key vault (on a good day) or an environment variable (on a less good day), which the client application can then use. You had a secret-free identity, and the first thing you did was invent and manage a new secret. There is a better way.

Your workload can use the workload identity it already has to register itself as a client with IdentityServer, at runtime, without a human in the loop. The key to making this work is Dynamic Client Registration (DCR) in Duende IdentityServer.

By the end of this post, you will have a workload that connects to Duende IdentityServer, proves its identity to Entra, and walks away with a client_id it can use with your internal APIs secured by Duende IdentityServer.

What Is the Difference Between Workload Identity and Client Identity?

Before we continue, let's make sure we understand the two types of identities we are talking about. The same workload has two identities, issued by two different authorities:

  • A workload identity is the identity assigned to running software: an app, a service, a container. In Azure, the concrete form of that is a managed identity, an identity Azure creates and rotates for you and that Microsoft Entra ID issues tokens for. Your workload asks Azure for a token, gets a JWT signed by Entra, and that token says "this is the orders service, in this tenant, and here is its object id."
  • A client identity is an OAuth 2.0 concept: a client_id (and usually a secret or a key) that an authorization server like Duende IdentityServer recognizes. It lets a service request an access token and call an API.

Two identities for the same workload, and normally nothing connects them. Dynamic Client Registration (DCR) connects them. It is the standard (RFC 7591) way for a client to register itself with an authorization server, and Duende IdentityServer supports it. You let the workload authenticate that registration call with the Entra identity it already has.

Note: there may be confusion between a few Entra terms that sound similar. A managed identity is the runtime credential Azure hands your workload. A service principal is the directory object in Entra that backs the managed identity. Identity Federation (specifically Workload Identity Federation) is a separate mechanism that lets workloads running in a Kubernetes cluster or outside Azure (GitHub Actions, other clouds) get an Entra token without a stored secret. We will come back to that one at the end of this article.

How Does a Workload Register Itself as a Client?

Let's start with the handshake that lets you create a client identity from a workload identity.

The workload gets an Entra token. It presents that token to the DCR endpoint. IdentityServer validates the token against Entra, a custom validator inspects the claims and decides whether to register a client, and the workload receives credentials. From there, it is a normal machine-to-machine call using the client credentials flow.

sequenceDiagram
    participant W as Workload (managed identity)
    participant E as Microsoft Entra ID
    participant I as Duende IdentityServer (DCR endpoint)
    participant A as API
    W->>E: Request token (DefaultAzureCredential)
    E-->>W: Entra access token (JWT)
    W->>I: POST /connect/dcr (Bearer: Entra token)
    I->>E: Validate token via Entra metadata
    I->>I: Custom validator checks idtyp + tid + oid, shapes client
    I-->>W: client_id + client_secret
    W->>I: Request access token (client_credentials)
    I-->>W: Access token
    W->>A: Call API (Bearer: access token)
    A-->>W: 200 OK

How Do You Protect the DCR Endpoint With Microsoft Entra ID?

The DCR endpoint comes from the Duende.IdentityServer.Configuration package. In this article, we'll host it in the same process as IdentityServer, which keeps the moving parts down while you get the flow working. You can split it onto its own host later, and we will come back to what that takes.

Never expose the DCR endpoint unauthenticated; protect it with an ASP.NET Core authorization policy like any other endpoint. Use Entra as the authority for that policy, so that only callers with an Entra-provided workload identity can reach it.

Csharp

var entra = builder.Configuration.GetSection("Entra");
var tenantId = entra["TenantId"]!;
var audience = entra["Audience"]!;

builder.Services.AddAuthentication()
    .AddJwtBearer("entra", options =>
    {
        options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
        options.Audience = audience;
        options.MapInboundClaims = false;
    });

builder.Services.AddAuthorization(opt =>
{
    opt.AddPolicy("dcr", policy =>
    {
        policy.AddAuthenticationSchemes("entra");
        policy.RequireAuthenticatedUser();
    });
});

Look at the authority: it points at Entra, not at IdentityServer. As a result, only a caller with a valid token from your Entra tenant gets through the door, and the claims from that token are available to the code behind the endpoint. Set MapInboundClaims to false so the claim types stay as Entra sends them (tid, oid) instead of being rewritten to the longer legacy names.

One detail trips people up here. A Microsoft Entra v2.0 access token carries the bare application (client) ID in its aud claim, not the api:// identifier URI you set on the app registration. So the audience you configure is that client ID, even though the workload requests its token using the api:// URI. Get this wrong and every registration call comes back as a 401 HTTP response.

DCR needs somewhere to store the clients it creates, and IdentityServer needs to read those same clients at the token endpoint. Both sides use the Entity Framework configuration store over the same ConfigurationDbContext, so a client registered through DCR is available when the workload requests a token. This sample points that context at SQLite, which keeps it to a single file with no server to run:

Csharp

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

// DCR is a licensed feature, so set the license key on both IdentityServer and the DCR services.
var licenseKey = builder.Configuration["Duende:LicenseKey"];

// IdentityServer reads clients, scopes, and resources from the EF configuration store.
builder.Services.AddIdentityServer(options =>
    {
        options.LicenseKey = licenseKey;
        options.EmitStaticAudienceClaim = true;
    })
    .AddConfigurationStore(options =>
    {
        options.ConfigureDbContext = b => b.UseSqlite(connectionString);
    });

// DCR writes newly registered clients into that same store.
builder.Services.AddIdentityServerConfiguration(options =>
    {
        options.LicenseKey = licenseKey;
    })
    .AddClientConfigurationStore();

// ...

app.MapDynamicClientRegistration().RequireAuthorization("dcr");

AddConfigurationStore gives IdentityServer its IClientStore over the database, and AddClientConfigurationStore gives DCR its IClientConfigurationStore over the same tables. Both take the license key, since DCR is a licensed feature. Swap SQLite for your production database of choice and the same code holds.

Because the clients live in a shared database rather than in memory, you can move the DCR endpoint to its own host and put the internet-facing registration endpoint and the internal token service on different networks. Both hosts still read and write the same records with no extra work.

How Do You Validate Workload Identity in a Custom DCR Validator?

Duende IdentityServer lets you extend DynamicClientRegistrationValidator and override individual steps of the registration. Inside every step, you get a context, and context.Caller is the ClaimsPrincipal of whoever authenticated to the endpoint. Since we protected the endpoint with Entra, context.Caller is the workload's Entra identity. We will want to make sure the caller is a workload and not a signed-in user who happens to have a token.

The grant-types step does double duty as our gate. It runs the workload check first, then the tenant and object ID checks, before settling the grant:

Csharp

protected override async Task<IStepResult> SetGrantTypesAsync(
    DynamicClientRegistrationContext context, CancellationToken ct)
{
    // Reject anything that is not an application-only (workload) token. Entra sets
    // idtyp to "app" for client-credentials tokens and "user" for signed-in users.
    // As a fallback for tokens without idtyp, an app-only token also carries no
    // scp (scope) claim.
    var identityType = context.Caller.FindFirst("idtyp")?.Value;
    var hasUserScopes = context.Caller.HasClaim(c => c.Type == "scp");
    if (identityType != "app" && (identityType is not null || hasUserScopes))
    {
        _logger.LogWarning("Rejected DCR request from a non-application identity (idtyp {IdType})", identityType);
        return await StepResult.Failure(
            "Only workload identities may register", "invalid_client_metadata");
    }

    // The Entra tenant the token was issued for.
    var tenantId = context.Caller.FindFirst("tid")?.Value;
    if (tenantId != _options.AllowedTenantId)
    {
        _logger.LogWarning("Rejected DCR request from tenant {TenantId}", tenantId);
        return await StepResult.Failure(
            "Registration is not allowed for this tenant", "invalid_client_metadata");
    }

    // The workload's own object id (the managed identity's service principal).
    var objectId = context.Caller.FindFirst("oid")?.Value;
    if (objectId is null || !_options.AllowedObjectIds.Contains(objectId))
    {
        _logger.LogWarning("Rejected DCR request from object id {ObjectId}", objectId);
        return await StepResult.Failure(
            "This workload is not allowed to register", "invalid_client_metadata");
    }

    // Force machine-to-machine only. Ignore whatever grant types the request asked for.
    context.Client.AllowedGrantTypes = ["client_credentials"];

    return await StepResult.Success();
}

The idtyp claim separates a workload from a user. Entra sets it to app for a client-credentials token and user for a signed-in user, so requiring the app claim keeps human tokens out even when they come from the right tenant. One catch: idtyp is an optional claim, so you have to enable it on the app registration that fronts the DCR endpoint before you can rely on it:

For tokens that predate that configuration, an app-only token also has no scp (scope) claim, which the fallback uses.

After that, it validates two more claims. tid is the Entra tenant, and checking it stops a valid token from some other directory from ever registering anything. oid is the workload's own object ID, and matching it against an allow-list turns "any managed identity in my tenant" into "these specific workloads I decided to trust." Without it, every identity in your tenant can register a client, which is almost certainly not what you want.

Once the checks pass, the validator shapes the client configuration. The workload authenticates as itself, machine-to-machine, so we force the client_credentials grant and ignore whatever the request asked for. A dynamically registered client should never get more than it needs. The scopes step continues the theme:

Csharp

protected override async Task<IStepResult> SetScopesAsync(
    DynamicClientRegistrationContext context, CancellationToken ct)
{
    var requested = context.Request.Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries)
                    ?? [];

    var granted = requested.Intersect(_options.AllowedScopes).ToArray();
    foreach (var scope in granted)
    {
        context.Client.AllowedScopes.Add(scope);
    }

    // Short-lived tokens and no refresh tokens for a machine client.
    context.Client.AccessTokenLifetime = 300;
    context.Client.AllowOfflineAccess = false;

    return await StepResult.Success();
}

The client gets only the intersection of what it asked for and what you allow; its access tokens live five minutes, and it gets no refresh tokens because a machine that can re-authenticate anytime it likes has no reason to hold one.

Short-lived tokens raise an obvious question: does the client re-request a token every five minutes? In practice, yes, and that is fine. The client credentials flow has no refresh token by design. When the access token expires, the workload asks the token endpoint for a new one with its credentials, which is an inexpensive call. You do not have to write that loop yourself either. Duende.AccessTokenManagement caches the token, hands it to your HttpClient, and fetches a fresh one when it expires.

Register the validator, and the endpoint uses it:

Csharp

builder.Services.AddTransient<IDynamicClientRegistrationValidator, EntraWorkloadDcrValidator>();

How Do You Register and Call the API From the Workload?

The workload's job is short. Get an Entra token, call DCR, and use the credentials that come back. In the sample, the workload is a small web app with a single GET /register-and-call endpoint, so you can deploy it to Azure App Service, give it a managed identity, and hit it in a browser. DefaultAzureCredential handles the first part, using the managed identity when the code runs in Azure and falling back to your az login session on your machine. The endpoint gets an HttpClient from IHttpClientFactory, shown as http below.

Csharp

// Get a Microsoft Entra ID token for this workload's managed identity.
var credential = new DefaultAzureCredential();
var entraToken = await credential.GetTokenAsync(
    new TokenRequestContext([dcrAudience]));

// Register a client through DCR, presenting the Entra token as the bearer.
var registration = await http.RegisterClientAsync(new DynamicClientRegistrationRequest
{
    Address = $"{identityServer}/connect/dcr",
    Token = entraToken.Token,
    Document = new DynamicClientRegistrationDocument
    {
        ClientName = "orders-service",
        GrantTypes = { "client_credentials" },
        Scope = "simple-api"
    }
});

The RegisterClientAsync extension and the request and document types come from Duende.IdentityModel, so you are not hand-rolling the HTTP call. The Token is the Entra token, presented as the bearer on the registration request, which is exactly what our endpoint policy expects.

From here, the workload has a client_id and client_secret, and the rest is a client credentials request followed by an API call:

Csharp

var disco = await http.GetDiscoveryDocumentAsync(identityServer);
var tokenResponse = await http.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
    Address = disco.TokenEndpoint,
    ClientId = registration.ClientId!,
    ClientSecret = registration.ClientSecret!,
    Scope = "simple-api"
});

http.SetBearerToken(tokenResponse.AccessToken!);
var apiResponse = await http.GetStringAsync($"{simpleApi}/identity");

Call the endpoint, and the workload registers itself and calls the API with no manual client provisioning involved.

How Do You Use This Pattern Outside Azure With Workload Identity Federation?

The pattern is not tied to Azure. The validator gates on whatever identity authenticated to the endpoint, and the endpoint trusts whatever issuer you configured. Swap the authority, and a GitHub Actions job, an AKS pod, or a workload in another cloud can register the same way through Workload Identity Federation. What changes is the issuer you trust and the claims you check. The validator logic stays the same.

What Should You Consider Before Going to Production?

DCR hands out credentials at runtime without a human in the loop, so a few things are worth getting right before you turn it on.

  • Reject user tokens. Require idtyp of app so a signed-in user's token cannot register a client, even from your own tenant. Enable the idtyp optional claim on the app registration so the check has something to read.
  • Get the Entra plumbing right. The app registration needs a service principal in the tenant, or Entra refuses to issue tokens for it. On Azure App Service with a user-assigned managed identity, set AZURE_CLIENT_ID so DefaultAzureCredential picks the right one. Both are quiet failures that look like auth bugs.
  • Check the tenant every time. The tid check keeps a valid token from another directory out. Do not skip it, especially with multi-tenant Entra apps in the picture.
  • Allow-list the workloads. Matching oid against a known set separates "workloads I trust" from "anyone in my tenant." Treat that list as security configuration.
  • Use a real database and migrations. The sample uses SQLite with EnsureCreated to stay simple. In production, point the configuration store at your database and manage its schema with EF migrations, so registered clients survive restarts, and the schema is versioned.
  • Make re-registration idempotent. Workloads restart. Decide whether a second registration from the same oid returns the existing client or creates a duplicate, and build for it rather than discovering it in production.
  • Store secrets hashed. Dynamically registered clients get generated secrets, and your store should keep them hashed, never in plain text. If you want to go further, a private_key_jwt client removes the shared secret entirely.
  • Audit every registration. Log who registered what and when. A client that appears at runtime should never be a surprise later.

Summary and Key Takeaways

Your workload started with an identity it could prove without any secret, and it ended with an OAuth client it created with that same workload identity. The trust you already established in Entra flows straight through to the client identity Duende IdentityServer issues, and it happens through business logic you control.

From here, the pattern stretches in a few directions. Point the endpoint at a different issuer, and the same validator handles workloads on GitHub Actions, AKS, a Kubernetes cluster, or another cloud through Workload Identity Federation. Register clients with private_key_jwt instead of a shared secret and you close the last gap where a shared secret exists at all. And once clients register themselves at runtime, managing their lifecycle (rotating, updating, or removing them) becomes the next thing worth automating, which RFC 7592 covers.

The full sample, with all three projects and an Azure deploy script, is ready to run. You can find the sample on GitHub. Give it a try, and let us know how it goes.

Related Articles