Password Migration with Entra External ID – part 2

This post continues where the previous post ended with configuring a custom extension for password migration in Entra External ID. Having completed the configuration in Entra, we can now focus on what we have to do in code. The code here will be an aspnet webapi in the classic setup with Program.cs and a Controller handling the API endpoints.

One important change to highlight is that when you introduce the password migration extension, and add the certificate as the KeyCrendetials, then the access token passed to your API will be encrypted. If you only do the other extensions, as explained in the very first post, you don’t need to handle the JWE encrypted token. I highlight this here if you plan to add password migration to an already existing extension you have.

Main topics to understand

There are three main topics to understand when implementing a custom authentication extension:

  1. The middleware – what does Program.cs need to look like?
  2. The request/response model Entra is using for interacting with extension APIs
  3. How you validate a password when migrating users
  4. How to get the Entra configuration right so your API actually gets called
  5. From PoC to scale….

Sample code is available in this github repo.

The middleware – Program.cs

Entra is calling your API using an access token acquired via client_credentials where the aud claim is your app with the CustomAuthenticationExtension.Receive.Payload (see prev post). The wiring in Program.cs must there for accept tokens issued by your Entra External tenant with the specific aud claim. To make things worse, it is not a JWT access token. It is a JWE encrypten token where the KeyVault certificate is used to encrypt the token (also see prev post where KeyCredentials is updated). Program.cs needs to read the certificate from KeyVault at startup and tell the middleware to use it for decrypting what is passed in the HTTP header for Authorization. If you don’t, the aspnet middleware will reject the call.

The helper class KeyVaultHelper reads the certificate from KeyVault and keeps it in a static X509Certificate2 variable. The TokenDecryptionKey is then used to set and indicate that when an encrypted JWT token arrives, decrypt it using this certificate.

var builder = WebApplication.CreateBuilder(args);

// pre-load the certificate from KeyVault and do it here as we use it for TokenDecryptionKey below
var kvh = new KeyVaultHelper( builder.Configuration );
kvh.LoadCertificateFromKeyVault();

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(
        jwtOptions => {
            jwtOptions.TokenValidationParameters = new TokenValidationParameters() {
                ValidateIssuerSigningKey = true,
                ValidateIssuer = true,
                ValidIssuer = $"{instance}{tenantId}/v2.0",
                ValidateLifetime = true,
                RequireExpirationTime = true,
                AudienceValidator = CustomAudienceValidator,
                // Once you add password migration and configure tokenEncryptionId in the app manifest
                // you will get an encrypted access token. In fact, it's not a JWT but a JWE.
                // We need to add the cert here so the aspnet middleware can decrypt it 
                TokenDecryptionKey = new X509SecurityKey(KeyVaultHelper.Certificate)

            };
        }, 

If your custom authentication extension does not use password migration, don’t use this extra code. Also, when the migration period is over, you can remove this extra code in the next release (don’t forget to remove the KeyCredentials in the app manifest too).

There is a custom audience validator in the above code, and that is because you might have multiple auth extensions configured to call your API. The CustomAudienceValidator checks the incoming aud claim against configuration in appsettings.json to see if it is a valid audience.

The request/response model Entra is using

The request/response model Entra is using for password migration follows the pattern for custom authentication extensions. The request JSON will look like below. The authenticationContext holds the relevant data where clientServicePrincipal section describes the app the end user is trying to sign in to, and the user section gives you information on who the end user is. Notice also the encryptedPasswordContext attribute that holds an encrypted JWT token containing the password.

{
  "type": "microsoft.graph.authenticationEvent.passwordSubmit",
  "source": "/tenants/11111111-2222-3333-4444-555555555555/applications/99999999-2222-3333-4444-555555555555",
  "data": {
    "@odata.type": "microsoft.graph.onPasswordSubmitCalloutData",
    "tenantId": "11111111-2222-3333-4444-555555555555",
    "authenticationEventListenerId": "1aeebe49-233d-4a1f-afe5-5c08cb8172ff",
    "customAuthenticationExtensionId": "761fcc06-379a-4544-b2b3-f5aa1dc4ae0d",
    "encryptedPasswordContext": "eyJhbGc...iwy4g",
    "authenticationContext": {
      "correlationId": "a2b1f0dd-19f6-43a9-be18-3ba36ad419a0",
      "client": {
        "ip": "321.456.789.255",
        "locale": "en-gb",
        "market": "en-gb"
      },
      "protocol": "OAUTH2.0",
      "clientServicePrincipal": {
        "id": "a70c1626-70cf-4657-9e4e-bbd8e8908d4e",
        "appId": "99999999-2222-3333-4444-555555555555",
        "appDisplayName": "ciam-test-app",
        "displayName": "ciam-test-app"
      },
      "resourceServicePrincipal": null,
      "user": {
        "id": "77777777-2222-3333-4444-555555555555",
        "userPrincipalName": "77777777-2222-3333-4444-555555555555@foobar.onmicrosoft.com",
        "userType": "Member",
        "createdDateTime": "2026-09-15T13:08:10Z",
        "displayName": "John Doe",
        "mail": "johndoe@live.com"
      }
    },
    "userSignUpInfo": null,
    "otpContext": null
  }
}

The method in the sample code uses the KeyVaultHelper class (_kv) to decrypt the encryptedPasswordContext value. Inside you have the userid the user typed is repeated, the password in clear text (feel the responsability!) and a nonce you need to return in your response.

string encryptedPasswordContext = request.data!.encryptedPasswordContext!;
string? decryptedPayload = _kv.DecryptRsa( encryptedPasswordContext! );
string? jsonPayload = _kv.DecodeRsa( decryptedPayload! );
JsonNode? payload = JsonNode.Parse(jsonPayload!);
string? username = payload!["username"]?.ToString();
string? password = payload!["user-password"]?.ToString();
string? nonce = payload["nonce"]?.ToString();

After you have validated the password, you can respond to Entra in the following ways

  • Passord was incorrect – retry
  • Password was correct – yay!
  • Password was correct, but it is weak – show the “set new pasword UI” before continuing
  • User is blocked in older identity provider – block user in Entra too

A happy path with “password was correct” the following JSON should be returned.

{
  "data": {
    "@odata.type": "microsoft.graph.onPasswordSubmitResponseData",
    "actions": [
      {
        "@odata.type": "microsoft.graph.passwordSubmit.MigratePassword"
      }
    ],
    "nonce": "d7c30ed9-2d8e-40c9-aee8-a205e9734308"
  }
}

Validate the username/password with old system

The sample code uses Auth0 to simulate an identity provider being migrated from (sorry!). What you have to do in your API endpoint is to make a call to that identity provider to validate that the username and password is correct. This can be tricky, but with Auth0 it’s very easy (that’s why I choose it). You can sign-up for a free developer instance of Auth0.

In the Auth0 portal, you do these four steps

  • Create Application in the Auth0 portal. Select Regular Web Application or Machine to Machine Application
  • In Application > Advanced Settings > Grant Types, check Password to enable
  • Copy DomainClient ID and Client Secret from applications page
  • Create a test user with a password of your choice

With the Grant Type Password on the Auth0 app you can validate username/password in a single API call.

$body = @{
    grant_type = "http://auth0.com/oauth/grant-type/password-realm"
    realm      = "Username-Password-Authentication"
    username   = $Auth0Username
    password   = $Auth0Password
    client_id  = $Auth0ClientId
    client_secret = $Auth0ClientSecret
    scope      = "openid profile email"
}

$response = Invoke-RestMethod -Uri "https://$Auth0TenantDomain/oauth/token" -Method Post -ContentType "application/json" -Body ($body | ConvertTo-Json -Compress) 

The C# code in your API endpoint then becomes as simple as this (given the helper class handles the HTTP call). When the authenticate call to Auth0 succeeds, all we need to do is to determind if we are dealing with a weak password or not and singal that to Entra.

PasswordMigrateResponse passwordMigrateResponse = PasswordMigrateResponse.PasswordIncorrect;
Auth0Response? auth0Response = _auth0.Authenticate( username!, password! );
if (null != auth0Response) {
   // short or not containing upper/lower/special
   if (password!.Length <= 8 || !(password.Any(char.IsUpper) && password.Any(char.IsLower) && password.Any(c => !char.IsLetterOrDigit(c)))) {
       passwordMigrateResponse = PasswordMigrateResponse.MigrateWeakPassword;
   } else {
       passwordMigrateResponse = PasswordMigrateResponse.MigratePassword;
   }
}
return AuthenticationEventResponse.PasswordSubmitResponse(AuthenticationEvent.PasswordSubmit, passwordMigrateResponse, nonce! );

How do you get Entra configured correctly so your API gets called?

If you are wondering about this, the previous post shows you how to configure this with powershell. At the time of writing this post, there was not Entra UI portal support and you have to do it all via powershell. It is not that complicated if you do it once in a fresh tenant, but if you repeat it or have multiple apps or User Flows, then it becomes a bit tricky. The accompanying powershell scripts in the github repo are constructed so that you can run them multiple times and they will do the right thing. If your configuration gets wrong and you want to start over, just run the remove script, but beware that it nukes all existing password migration extensions (if you have multiple).

From PoC to Scale…

There are many things to consider when taking a password migration solution from a PoC to production. Scale plays an important part and that you can control. Your end users behaviour is something you can’t control.

Scale

  • Deploy your password migration where it can scale, because once you start the migration period, EVERY end user trying to sign in will call your API. Migrated users will not call your API again, so over time the API hits will subside.
  • Make sure your API does not do anything that takes unnecessary performance. It sits between Entra and the old identity provider and should be as fast as it possibly can be.
  • Your old identity provider, like Auth0 in my sample – how will it react Monday morning when it gets thousands of calls all of a sudden. Do you need to scale up here?

What you can’t control

  • Starting the migration period at a peak time for the app is a bad idea. Imagine the start of a semester at a 20K+ University where all students suddenly signs in to your app first day of school. Choose your migration window wisely.