{"id":8017,"date":"2026-09-17T19:27:12","date_gmt":"2026-09-17T17:27:12","guid":{"rendered":"https:\/\/blog.redbaronofazure.com\/?p=8017"},"modified":"2026-09-17T21:49:53","modified_gmt":"2026-09-17T19:49:53","slug":"password-migration-with-entra-external-id-part-2","status":"publish","type":"post","link":"https:\/\/blog.redbaronofazure.com\/?p=8017","title":{"rendered":"Password Migration with Entra External ID \u2013 part 2"},"content":{"rendered":"\n<p>This post continues where the <a rel=\"noreferrer noopener\" href=\"https:\/\/blog.redbaronofazure.com\/?p=8006\" target=\"_blank\">previous post<\/a> 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 <a href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\/blob\/main\/Program.cs\" target=\"_blank\" rel=\"noreferrer noopener\">Program.cs<\/a> and a Controller handling the API endpoints.<\/p>\n\n\n\n<p>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 <a rel=\"noreferrer noopener\" href=\"https:\/\/blog.redbaronofazure.com\/?p=7989\" target=\"_blank\">first post<\/a>, you don&#8217;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.<\/p>\n\n\n\n<p>Sample code is available in this github <a rel=\"noreferrer noopener\" href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\" target=\"_blank\">repo<\/a>.<\/p>\n\n\n\n<h2>Main topics to understand<\/h2>\n\n\n\n<p>There are a couple topics to understand when implementing a custom authentication extension:<\/p>\n\n\n\n<ol><li>The middleware &#8211; what does Program.cs need to look like?<\/li><li>The request\/response model Entra is using for interacting with extension APIs<\/li><li>How you validate a password when migrating users<\/li><li>How to get the Entra configuration right so your API actually gets called<\/li><li>From PoC to scale&#8230;.<\/li><\/ol>\n\n\n\n<h2>The middleware &#8211; Program.cs<\/h2>\n\n\n\n<p>Entra is calling your API using an access token acquired via client_credentials where the aud claim is your app with the CustomAuthenti<em>cationExtension.Receive.Payload<\/em> (see prev post). The wiring in <a href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\/blob\/main\/Program.cs\" target=\"_blank\" rel=\"noreferrer noopener\">Program.cs<\/a> 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). <a href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\/blob\/main\/Program.cs\" target=\"_blank\" rel=\"noreferrer noopener\">Program.cs<\/a> 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&#8217;t, the aspnet middleware will reject the call.<\/p>\n\n\n\n<p>The helper class <a href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\/blob\/main\/Helpers\/KeyVaultHelper.cs#L66\" target=\"_blank\" rel=\"noreferrer noopener\">KeyVaultHelper reads<\/a> 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"csharp\" class=\"language-csharp\">var builder = WebApplication.CreateBuilder(args);\n\n\/\/ pre-load the certificate from KeyVault and do it here as we use it for TokenDecryptionKey below\nvar kvh = new KeyVaultHelper( builder.Configuration );\nkvh.LoadCertificateFromKeyVault();\n\nbuilder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)\n    .AddMicrosoftIdentityWebApi(\n        jwtOptions =&gt; {\n            jwtOptions.TokenValidationParameters = new TokenValidationParameters() {\n                ValidateIssuerSigningKey = true,\n                ValidateIssuer = true,\n                ValidIssuer = $\"{instance}{tenantId}\/v2.0\",\n                ValidateLifetime = true,\n                RequireExpirationTime = true,\n                AudienceValidator = CustomAudienceValidator,\n                \/\/ Once you add password migration and configure tokenEncryptionId in the app manifest\n                \/\/ you will get an encrypted access token. In fact, it's not a JWT but a JWE.\n                \/\/ We need to add the cert here so the aspnet middleware can decrypt it \n                TokenDecryptionKey = new X509SecurityKey(KeyVaultHelper.Certificate)\n\n            };\n        }, \n<\/code><\/pre>\n\n\n\n<p>If your custom authentication extension does not use password migration, don&#8217;t use this extra code. Also, when the migration period is over, you can remove this extra code in the next release (don&#8217;t forget to remove the KeyCredentials in the app manifest too).<\/p>\n\n\n\n<p>There is a <a href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\/blob\/main\/Program.cs#L126\" target=\"_blank\" rel=\"noreferrer noopener\">custom audience validator<\/a> 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 <a href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\/blob\/main\/appsettings.json#L22\" target=\"_blank\" rel=\"noreferrer noopener\">appsettings.json<\/a> to see if it is a valid audience.<\/p>\n\n\n\n<h2>The request\/response model Entra is using<\/h2>\n\n\n\n<p>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 <em>authenticationContext<\/em> holds the relevant data where <em>clientServicePrincipal<\/em> section describes the app the end user is trying to sign in to, and the <em>user<\/em> section gives you information on who the end user is. Notice also the <em>encryptedPasswordContext<\/em> attribute that holds an encrypted JWT token containing the password.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"json\" class=\"language-json\">{\n  \"type\": \"microsoft.graph.authenticationEvent.passwordSubmit\",\n  \"source\": \"\/tenants\/11111111-2222-3333-4444-555555555555\/applications\/99999999-2222-3333-4444-555555555555\",\n  \"data\": {\n    \"@odata.type\": \"microsoft.graph.onPasswordSubmitCalloutData\",\n    \"tenantId\": \"11111111-2222-3333-4444-555555555555\",\n    \"authenticationEventListenerId\": \"1aeebe49-233d-4a1f-afe5-5c08cb8172ff\",\n    \"customAuthenticationExtensionId\": \"761fcc06-379a-4544-b2b3-f5aa1dc4ae0d\",\n    \"encryptedPasswordContext\": \"eyJhbGc...iwy4g\",\n    \"authenticationContext\": {\n      \"correlationId\": \"a2b1f0dd-19f6-43a9-be18-3ba36ad419a0\",\n      \"client\": {\n        \"ip\": \"321.456.789.255\",\n        \"locale\": \"en-gb\",\n        \"market\": \"en-gb\"\n      },\n      \"protocol\": \"OAUTH2.0\",\n      \"clientServicePrincipal\": {\n        \"id\": \"a70c1626-70cf-4657-9e4e-bbd8e8908d4e\",\n        \"appId\": \"99999999-2222-3333-4444-555555555555\",\n        \"appDisplayName\": \"ciam-test-app\",\n        \"displayName\": \"ciam-test-app\"\n      },\n      \"resourceServicePrincipal\": null,\n      \"user\": {\n        \"id\": \"77777777-2222-3333-4444-555555555555\",\n        \"userPrincipalName\": \"77777777-2222-3333-4444-555555555555@foobar.onmicrosoft.com\",\n        \"userType\": \"Member\",\n        \"createdDateTime\": \"2026-09-15T13:08:10Z\",\n        \"displayName\": \"John Doe\",\n        \"mail\": \"johndoe@live.com\"\n      }\n    },\n    \"userSignUpInfo\": null,\n    \"otpContext\": null\n  }\n}<\/code><\/pre>\n\n\n\n<p>The <a href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\/blob\/main\/Controllers\/ApiController.cs#L300\" target=\"_blank\" rel=\"noreferrer noopener\">method in the sample code<\/a> uses the KeyVaultHelper class (_kv) to decrypt the <em>encryptedPasswordContext<\/em> 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"csharp\" class=\"language-csharp\">string encryptedPasswordContext = request.data!.encryptedPasswordContext!;\nstring? decryptedPayload = _kv.DecryptRsa( encryptedPasswordContext! );\nstring? jsonPayload = _kv.DecodeRsa( decryptedPayload! );\nJsonNode? payload = JsonNode.Parse(jsonPayload!);\nstring? username = payload![\"username\"]?.ToString();\nstring? password = payload![\"user-password\"]?.ToString();\nstring? nonce = payload[\"nonce\"]?.ToString();\n<\/code><\/pre>\n\n\n\n<p>After you have validated the password, you can respond to Entra in the following ways<\/p>\n\n\n\n<ul><li>Passord was incorrect &#8211; retry <\/li><li>Password was correct &#8211; yay!<\/li><li>Password was correct, but it is weak &#8211; show the &#8220;set new pasword UI&#8221; before continuing<\/li><li>User is blocked in older identity provider &#8211; block user in Entra too<\/li><\/ul>\n\n\n\n<p>A happy path with &#8220;password was correct&#8221; the following JSON should be returned.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"json\" class=\"language-json\">{\n  \"data\": {\n    \"@odata.type\": \"microsoft.graph.onPasswordSubmitResponseData\",\n    \"actions\": [\n      {\n        \"@odata.type\": \"microsoft.graph.passwordSubmit.MigratePassword\"\n      }\n    ],\n    \"nonce\": \"d7c30ed9-2d8e-40c9-aee8-a205e9734308\"\n  }\n}<\/code><\/pre>\n\n\n\n<h2>Validate the username\/password with old system<\/h2>\n\n\n\n<p>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&#8217;s very easy (that&#8217;s why I choose it). You can sign-up for a free developer instance of Auth0.<\/p>\n\n\n\n<p>In the Auth0 portal, you do these four steps<\/p>\n\n\n\n<ul><li>Create Application in the Auth0 portal. Select&nbsp;<code>Regular Web Application<\/code>&nbsp;or&nbsp;<code>Machine to Machine Application<\/code><\/li><li>In Application &gt; Advanced Settings &gt; Grant Types, check&nbsp;<code>Password<\/code>&nbsp;to enable<\/li><li>Copy&nbsp;<code>Domain<\/code>,&nbsp;<code>Client ID<\/code>&nbsp;and&nbsp;<code>Client Secret<\/code>&nbsp;from applications page<\/li><li>Create a test user with a password of your choice<\/li><\/ul>\n\n\n\n<p>With the Grant Type <em>Password<\/em> on the Auth0 app you can validate username\/password in a single API call.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"powershell\" class=\"language-powershell\">$body = @{\n    grant_type = \"http:\/\/auth0.com\/oauth\/grant-type\/password-realm\"\n    realm      = \"Username-Password-Authentication\"\n    username   = $Auth0Username\n    password   = $Auth0Password\n    client_id  = $Auth0ClientId\n    client_secret = $Auth0ClientSecret\n    scope      = \"openid profile email\"\n}\n\n$response = Invoke-RestMethod -Uri \"https:\/\/$Auth0TenantDomain\/oauth\/token\" -Method Post -ContentType \"application\/json\" -Body ($body | ConvertTo-Json -Compress) \n<\/code><\/pre>\n\n\n\n<p>The C# code in your API endpoint then becomes as simple as this (given the <a rel=\"noreferrer noopener\" href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\/blob\/main\/Helpers\/Auth0Helper.cs#L25\" target=\"_blank\">helper class<\/a> 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"csharp\" class=\"language-csharp\">PasswordMigrateResponse passwordMigrateResponse = PasswordMigrateResponse.PasswordIncorrect;\nAuth0Response? auth0Response = _auth0.Authenticate( username!, password! );\nif (null != auth0Response) {\n   \/\/ short or not containing upper\/lower\/special\n   if (password!.Length &lt;= 8 || !(password.Any(char.IsUpper) &amp;&amp; password.Any(char.IsLower) &amp;&amp; password.Any(c =&gt; !char.IsLetterOrDigit(c)))) {\n       passwordMigrateResponse = PasswordMigrateResponse.MigrateWeakPassword;\n   } else {\n       passwordMigrateResponse = PasswordMigrateResponse.MigratePassword;\n   }\n}\nreturn AuthenticationEventResponse.PasswordSubmitResponse(AuthenticationEvent.PasswordSubmit, passwordMigrateResponse, nonce! );\n<\/code><\/pre>\n\n\n\n<h2>How do you get Entra configured correctly so your API gets called?<\/h2>\n\n\n\n<p>If you are wondering about this, the previous <a rel=\"noreferrer noopener\" href=\"https:\/\/blog.redbaronofazure.com\/?p=8006\" target=\"_blank\">post<\/a> 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 <a rel=\"noreferrer noopener\" href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\/tree\/main\/scripts\" target=\"_blank\">repo<\/a> 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 <a rel=\"noreferrer noopener\" href=\"https:\/\/github.com\/cljung\/CustomAuthenticationExtension\/blob\/main\/scripts\/Remove-PasswordMigration-Extension.ps1\" target=\"_blank\">remove<\/a> script, but beware that it nukes all existing password migration extensions (if you have multiple).<\/p>\n\n\n\n<h2>From PoC to Scale&#8230;<\/h2>\n\n\n\n<p>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&#8217;t control.<\/p>\n\n\n\n<p><strong>Scale<\/strong><\/p>\n\n\n\n<ul><li>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.<\/li><li>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.<\/li><li>Your old identity provider, like Auth0 in my sample &#8211; how will it react Monday morning when it gets thousands of calls all of a sudden. Do you need to scale up here?<\/li><\/ul>\n\n\n\n<p><strong>What you can&#8217;t control<\/strong><\/p>\n\n\n\n<ul><li>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.<\/li><\/ul>\n","protected":false},"excerpt":{"rendered":"<p>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 [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":[],"categories":[453,457],"tags":[461,463],"_links":{"self":[{"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=\/wp\/v2\/posts\/8017"}],"collection":[{"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=8017"}],"version-history":[{"count":11,"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=\/wp\/v2\/posts\/8017\/revisions"}],"predecessor-version":[{"id":8034,"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=\/wp\/v2\/posts\/8017\/revisions\/8034"}],"wp:attachment":[{"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=8017"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=8017"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=8017"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}