Blog Post

Microsoft Developer Community Blog
6 MIN READ

SecretLess App Registrations in Entra ID

Andreas_Helland's avatar
Andreas_Helland
Iron Contributor
Jan 31, 2025

Passwordless authentication for users is good. Now we can have secretless OAuth flows as well. And that is also good.

Identity is an area that has gotten a lot of attention the past few years and is now essential for every developer to have at least a basic level of understanding of. (We can probably debate what constitutes basic - not everyone has to be able to implement an authentication library from scratch, but everyone should have an idea of things to think about when adding a sign-in button to their web app.)

On the .NET side we've seen good things with standards compliant libraries provided by Microsoft. With Blazor it has gotten easier to implement patterns like Backend for Frontends (BFF) to move the auth process away from the client side. In .NET 9 this was improved upon further with built-in authentication state handling.

When it comes to authenticating across services, like your web app needing to reach into a database, if you host your apps on Azure you should definitely be using Managed Identity. There are different ways to use Managed Identity and we will not be diving into all those details here, but basically you don't need to use a connection string with an embedded password to access that database any longer. Passwordless backends if you will.

What has been sticking out as a sore thumb here is that even if you are top in your class when it comes to eliminate passwords across resources you have still been stuck with it in one place. The client secret you use for OAuth.

Entra resources vs Azure resources

Entra is very much a central part of Azure subscriptions, but at the same time there are nuances between how you can configure access. The Azure resources deal with existing identities whereas Entra deals with providing identities. (And a couple of other things as well of course.) So, as a developer, part of setting up a login scheme for end-users in your application have involved registering an app, generating a secret and pasting it into your appsettings.json. (We're leaving out things like storing it in a Key Vault or another safe place.)

If you are more ambitious it could also be that you have undertaken the work to generate a certificate and uploading the public key to Entra ID to use instead of secrets. It is a bit of extra work, but it is also a good approach.

A few weeks ago an option to combine Entra and Azure to step things up a notch was introduced  to allow you access cloud resources across tenants without secrets.

This means that we can get rid of the secret without resorting to certificates. The documentation for Configure an application to trust a managed identity (preview) - Microsoft Entra Workload ID on Microsoft Learn.

Making secretless auth work

There's three parts to making this work. You need a user-assigned managed identity. You need to attach this to an app registration. And you need to adapt your code. Depending on how you have implemented authentication at the moment maybe that's just putting some different values into appsettings.json, but it could be that you need to edit the actual code as well. I have chosen an implementation in my BFF that requires some minor tweaks to what I had. A complete sample can be found on this GitHub:

 

The important code looks like this:

builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddCookie("MicrosoftOidc")
    .AddMicrosoftIdentityWebApp(microsoftIdentityOptions =>
    {
        if (builder.Environment.IsDevelopment())
        { 
            //Certificate is used for auth for local dev
            microsoftIdentityOptions.ClientCredentials = new CredentialDescription[] {
            CertificateDescription.FromKeyVault(keyvaultUrl,keyvaultSecret)};
        }
        else
        {
            //When deployed to Azure use a User Assigned Managed Identity
            CredentialDescription uami = new CredentialDescription();
            uami.ManagedIdentityClientId = uamiId;
            uami.TokenExchangeUrl = "api://AzureADTokenExchange";
            uami.SourceType = CredentialSource.SignedAssertionFromManagedIdentity;
            microsoftIdentityOptions.ClientCredentials = new CredentialDescription[] { uami };

But didn't I just say certificates were not required? Well, managed identity works fine when running on Azure, but doesn't work when debugging locally. So, what I do is using a certificate for debugging purposes and having a switch to only use the managed identity when deployed to Azure. Actually not a lot of code either way. (Certificates also still have the benefit of being a viable option for on-premises apps using Entra ID as the identity plane, but that's out of scope for this blog post.)

What took some more effort was to make it a good developer experience. I wanted to be able to automate things so I can just hit F5 to debug and work locally and deploy to Azure afterwards with minimal effort. To do that I brought in .NET Aspire and the Azure Developer CLI.

The following Bicep code will create an app registration and attach both a certificate and a managed identity:

resource app 'Microsoft.Graph/applications@v1.0' = {
  displayName: appName
  uniqueName: appName
  keyCredentials: [
    {
      displayName: 'Credential from KV'
      usage: 'Verify'
      type: 'AsymmetricX509Cert'
      key: createAddCertificate.properties.outputs.certKey
      startDateTime: createAddCertificate.properties.outputs.certStart
      endDateTime: createAddCertificate.properties.outputs.certEnd
    }
  ]
  resource myMsiFic 'federatedIdentityCredentials@v1.0' = {
    name: '${app.uniqueName}/msiAsFic'
    description: 'Trust the workloads UAMI to impersonate the App'
    audiences: [
       'api://AzureADTokenExchange'
    ]
    issuer: '${environment().authentication.loginEndpoint}${tenant().tenantId}/v2.0'
    subject: webIdentity.properties.principalId
  }
  identifierUris: [
    identifierUri
  ]
  web: {
    redirectUris: [
      'https://localhost:7109/signin-oidc'
      'https://bff-web-app.${caeDomainName}/signin-oidc'
      'https://bff-web-app.internal.${caeDomainName}/signin-oidc'
    ]
  }
  api: {
    requestedAccessTokenVersion: 2
    oauth2PermissionScopes: [
      {
        adminConsentDescription: 'Weather.Get'
        adminConsentDisplayName: 'Weather.Get'
        value: 'Weather.Get'
        type: 'User'
        isEnabled: true
        userConsentDescription: 'Weather.Get'
        userConsentDisplayName: 'Weather.Get'
        id: guid('Weather.Get')
      }
    ]
  }
}

Orchestration is done through Aspire with the following code:

using Aspire.Hosting.Azure;

var builder = DistributedApplication.CreateBuilder(args);

var tenantId    = builder.AddParameter("TenantId");
var appName     = builder.AddParameter("AppName");

var appRegistration = builder.AddBicepTemplate(
    name: "App",
    bicepFile: "../infra/Graph/app-registration.bicep"
)
    .WithParameter("subjectName", "CN=bff.contoso.com")
    .WithParameter("appName", appName)
    .WithParameter("keyVaultName", AzureBicepResource.KnownParameters.KeyVaultName)
    .WithParameter("uamiName", "ficUami")
    .WithParameter("caeDomainName", "placeholder");

var clientId       = appRegistration.GetOutput("clientId");
var uamiId         = appRegistration.GetOutput("uamiId");
var keyVaultUrl    = appRegistration.GetOutput("keyVaultUrl");
var keyVaultSecret = appRegistration.GetOutput("keyVaultSecret");
var identifierUri  = appRegistration.GetOutput("identifierUri");

var weatherapi = builder.AddProject<Projects.WeatherAPI>("weatherapi")
    .WaitFor(appRegistration)
    .WithEnvironment("TenantId", tenantId)
    .WithEnvironment("ClientId", clientId)
    .PublishAsAzureContainerApp((module, app ) => {});

builder.AddProject<Projects.BFF_Web_App>("bff-web-app")
    .WaitFor(appRegistration)
    .WithReference(weatherapi)
    .WithEnvironment("TenantId", tenantId)
    .WithEnvironment("ClientId", clientId)
    .WithEnvironment("UamiId", uamiId)
    .WithEnvironment("IdentifierUri", identifierUri)
    .WithEnvironment("KeyVaultUrl", keyVaultUrl)
    .WithEnvironment("KeyVaultSecret", keyVaultSecret)
    .WithExternalHttpEndpoints()
    .PublishAsAzureContainerApp((module, app) => { });

builder.Build().Run();

This is really nice - Aspire kicks off registration of an app in Entra ID and then passes the outputs of that directly into the app without the need to create a static appsettings.json on disk. (You still need to put in some values in the appsettings.json for Aspire though, but no secrets there either.)

If you inspect the file structure in my repo you might be confused about the fact that there are two app-registration.bicep files so let me clear up that part. When you hit F5 ../infra/Graph/app-registration.bicep will be used. If you run azd infra synth (to generate deployment time Bicep files for azd) it will use that as the source for outputting to ../infra/App/app-registration.bicep because it will assume a folder name matching the app name. Slightly confusing, but this is then used as part of the internal logic of azd for passing variables and parameters across.

I ended up wanting to have two separate files. One for working around a bug/restriction in azd where it doesn't support using dynamic types for the Graph extension. (Dynamic == extension microsoftGraphV1_0, non-dynamic == extension microsoftGraph.) The other being that when deploying we skip generating a certificate and we also need to make sure the right User-Assigned Managed Identity gets passed in which required some tuning of the Bicep files azd generated. You don't need to concern yourself with this when cloning the repo - just execute azd up to have a couple of container apps created and configured.

This sample code assumes that the managed identity and the app registration are in the same tenant, but technically we don't have to constrain ourselves to that as a scenario. You can configure use cases where the resources are hosted in one tenant, and the app is in another tenant (which raises further questions around single-tenant vs multi-tenant app registrations I suppose). That will however be another topic for another time. In the meantime; what are you waiting for? Get rid of those secrets 🙂

Updated Jan 29, 2025
Version 1.0
No CommentsBe the first to comment