{"id":8006,"date":"2026-09-16T20:18:40","date_gmt":"2026-09-16T18:18:40","guid":{"rendered":"https:\/\/blog.redbaronofazure.com\/?p=8006"},"modified":"2026-09-16T21:55:42","modified_gmt":"2026-09-16T19:55:42","slug":"password-migration-with-entra-external-id-part-1","status":"publish","type":"post","link":"https:\/\/blog.redbaronofazure.com\/?p=8006","title":{"rendered":"Password Migration with Entra External ID &#8211; part 1"},"content":{"rendered":"\n<p>Migrating to Entra Enternal ID from another identity provider brings with it the problem of how do you migrate the users&#8217; passwords. In the best of worlds, only the end users should know their passwords, which then becomes a migration problem. The solution has been around for a while that the new identity provider delegates to the old, soon to be retired, identity provider the password validation the first time an end user logs in to the new system. This was the case with Azure AD B2C and it is till the case for Entra External ID.<\/p>\n\n\n\n<p>The solution in Entra External ID is using a Custom Authentication Extension of type <em>PasswordSubmitCustomExtension<\/em> that makes an API call to your custom code so you can handle the password migration by calling the old identity provider. <\/p>\n\n\n\n<p>This post is the first of two and will show you the configuration required to setup such a solution. The second post will show the code behind the API that will show you migrating from Auth0 (sorry, I had to pick one. No offence, Auth0).<\/p>\n\n\n\n<p>The Microsoft <a rel=\"noreferrer noopener\" href=\"https:\/\/learn.microsoft.com\/en-us\/entra\/external-id\/customers\/how-to-migrate-passwords-just-in-time\" target=\"_blank\">documentation<\/a> is available but be prepared that it is long, hard to follow, and also inconclusive. It leaves you with more than a few gaps.<\/p>\n\n\n\n<h2>What challanges are we facing?<\/h2>\n\n\n\n<p>Besides the obvious &#8211; do you have an API in your old system that can authenticate a user by username\/password &#8211; there are a few challenges you are up for. They are:<\/p>\n\n\n\n<ol><li>You have no Entra Admin Portal UI support and will have to configure this via Powershell.<\/li><li>The Powershell module isn&#8217;t fully developed here, so the Powershell code will be kind of ugly.<\/li><li>When you have configured it, Entra will send not the usual JWT access token to your app but an encrypted JWE access token &#8211; which will make your aspnet authentication middleware unhappy without code changes<\/li><\/ol>\n\n\n\n<p>On the positive side, when you have configured it, it really works well!<\/p>\n\n\n\n<h2>Create a Certificate in KeyVault<\/h2>\n\n\n\n<p>First, you need to create a self-signed certificate in Azure KeyVault as documented <a rel=\"noreferrer noopener\" href=\"https:\/\/learn.microsoft.com\/en-us\/entra\/external-id\/customers\/how-to-migrate-passwords-just-in-time#213-generate-certificate-in-azure-key-vault\" target=\"_blank\">here<\/a>. To be fair, you could do without KeyVault and generate the certificate yourself, but since there will be a production migration here at the end, you should stick to using KeyVault.<\/p>\n\n\n\n<p>The KeyVault and the Azure subscription can be in your normal location and needs no relationship with the Entra External tenant. However, this certificate is more important than the Microsoft documentation tells you. Not only does it encrypt the password that the end user types in when trying to authenticate for the first time in the User Flow, it also <strong>encrypts the Entra access token<\/strong> being passed for authorization to your API. As soon as you have run a piece of powershell later, your access token will be a JWE and not a JWT which will completley disrupt your API. So timing configuration and code deployment is important here. KeyVault creates a self-signed certificate with a liftetime of a year, so the clock starts ticking for your migration period once you generated the certificate. For better timing of your production migration, consider generating your own self-signed certificate with openssl and upload it to KeyVault.<\/p>\n\n\n\n<p>As the documentation explains, after you have created it in KeyVault, download it in CER format as a file. We will get back to it with Powershell later when you add it as a KeyCredential to your custom extension app.<\/p>\n\n\n\n<h2>Extension attribute toBeMigrated<\/h2>\n\n\n\n<p>You need to create an extension attribute to track each users migration status. The documentation calls it <em>toBeMigrated<\/em>, but it could have any name you like. However, an extension attribute must be registered on an application and in this case, it needs to be registered on the <em>b2c-extensions<\/em> application. The below Powershell code creates the extension attribute.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"powershell\" class=\"language-powershell\">$b2cExt = Invoke-MgGraphRequest -Method GET -Uri \"https:\/\/graph.microsoft.com\/v1.0\/applications?`$filter=startswith(displayName, 'b2c-extension')\"\r\n$extAttr = \"extension_\"+$b2cExt.value.appId.Replace(\"-\",\"\")+\"_toBeMigrated\"\r\n\r\nInvoke-MgGraphRequest -Method POST -Uri \"https:\/\/graph.microsoft.com\/v1.0\/applications\/$($b2cExt.value.id)\/extensionProperties\" -Body \"{'name': 'toBeMigrated', 'dataType': 'Boolean', 'targetObjects':[ 'User' ] }\"\r\n<\/code><\/pre>\n\n\n\n<h2>Create the custom authentication extension for password migration<\/h2>\n\n\n\n<p>Entra needs to know where our endpoint to the password migration extension is. But as we learned it the previous <a rel=\"noreferrer noopener\" href=\"https:\/\/blog.redbaronofazure.com\/?p=7989\" target=\"_blank\">post<\/a>, Entra needs an app registration with the appropriate API permission to call our API. We assume that this appreg is already created and all we need to do is get a reference to it. <\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"powershell\" class=\"language-powershell\">$extApp = Invoke-MgGraphRequest -Method GET -Uri \"https:\/\/graph.microsoft.com\/v1.0\/applications?`$filter=displayName eq '$customExtensionAppName'\"\r<\/code><\/pre>\n\n\n\n<p>Once we have this app&#8217;s reference, we can register our extension. This is the powershell for registering the password migration extension. <strong>Note<\/strong> that you need to update the API endpoint <em>targetUrl<\/em> to meet your needs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"powershell\" class=\"language-powershell\">$extensionParams = @\"\r\n{\r\n  \"@odata.type\": \"#microsoft.graph.onPasswordSubmitCustomExtension\",\r\n  \"displayName\": \"OnPasswordSubmitCustomExtension\",\r\n  \"description\": \"Validate password\",\r\n  \"endpointConfiguration\": {\r\n    \"@odata.type\": \"#microsoft.graph.httpRequestEndpoint\",\r\n    \"targetUrl\": \"$(\"https:\/\/$apiHostingDomain\/api\/authenticationevent\/passwordmigration\")\"\r\n  },\r\n  \"authenticationConfiguration\": {\r\n    \"@odata.type\": \"#microsoft.graph.azureAdTokenAuthentication\",\r\n    \"resourceId\": \"$(\"api:\/\/$apiHostingDomain\/$($extApp.value.appId)\")\"\r\n  },\r\n  \"clientConfiguration\": {\r\n    \"timeoutInMilliseconds\": 2000,\r\n    \"maximumRetries\": 1\r\n  }\r\n}\r\n\"@\r\n\r\nInvoke-MgGraphRequest -Method POST -Uri \"https:\/\/graph.microsoft.com\/v1.0\/identity\/customAuthenticationExtensions\" -Body $extensionParams\r\n$authExt = Invoke-MgGraphRequest -Method GET -Uri \"https:\/\/graph.microsoft.com\/beta\/identity\/customAuthenticationExtensions\"\r\n$authExtPwdMigration = ($authExt.value | where {$_.displayName -eq \"OnPasswordSubmitCustomExtension\"})\r<\/code><\/pre>\n\n\n\n<h2>Bind the extension to your client application<\/h2>\n\n\n\n<p>The extension must be bound to your client app as otherwise, how would your client app know that it should call your API. First we need to retrieve the client app&#8217;s AppID, then we need to bind it to using the authentication listener. The binding points to two things: first the extensions itself, but second it also references the extension attribute in the <em>migrationPropertyId<\/em> value. This means &#8220;if this property is true, call this extension handler to take care of business&#8221;.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"powershell\" class=\"language-powershell\">$clientApp = Invoke-MgGraphRequest -Method GET -Uri \"https:\/\/graph.microsoft.com\/v1.0\/applications?`$filter=displayName eq '$appName'\"\r\n\r\n$evtListener = @\"\r\n{  \r\n    \"@odata.type\": \"#microsoft.graph.onPasswordSubmitListener\",  \r\n    \"conditions\": {  \r\n        \"applications\": {  \r\n            \"includeAllApplications\": false,  \r\n            \"includeApplications\": [  \r\n                {  \r\n                    \"appId\": \"$($clientApp.value[0].appId)\"  \r\n                }  \r\n            ]  \r\n        }  \r\n    },  \r\n    \"priority\": 500,  \r\n    \"handler\": {  \r\n        \"@odata.type\": \"#microsoft.graph.onPasswordMigrationCustomExtensionHandler\",  \r\n        \"migrationPropertyId\": \"$extAttr\",  \r\n        \"customExtension\": {  \r\n            \"id\": \"$($authExtPwdMigration.id)\"  \r\n        }  \r\n    }  \r\n}  \r\n\"@\r\n\r\nInvoke-MgGraphRequest -Method POST -Uri \"https:\/\/graph.microsoft.com\/beta\/identity\/authenticationEventListeners\" -Body $evtListener<\/code><\/pre>\n\n\n\n<h2>Add the key credentials to your extension app<\/h2>\n\n\n\n<p>In order for the password to be delivered securely and encrypted to your app, we need to add the KeyVault certificate to the extension app. At this point you must have downloaded the KeyVault certificate in the CER format to your local machine. Set the full path of the file to variable <em>$certFullPath<\/em>. Notice that the <em>tokenEncryptionKeyId<\/em> is set to the generated <em>KeyId<\/em> value. This will tell Entra to &#8220;please encrypt any tokens using this certificate&#8221;. Once you invoke the PATCH request in the below code, <strong>all<\/strong> API calls to your custom extension using the same extension appreg will use an encrypted JWE access token. This will require a code change explained in the next post.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"powershell\" class=\"language-powershell\">$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certFullPath)\r\n$certBase64 = [Convert]::ToBase64String($cert.RawData)\r\n\n$keyCredentials = @\"\r\n{\r\n  \"keyCredentials\": [\r\n    {\r\n      \"keyId\": \"$((New-Guid).Guid.ToString())\",\r\n      \"endDateTime\": \"$($cert.NotBefore.toUniversaltime().ToString(\"o\"))\",\r\n      \"startDateTime\": \"$($cert.NotAfter.toUniversaltime().ToString(\"o\"))\",\r\n      \"type\": \"AsymmetricX509Cert\",\r\n      \"usage\": \"Encrypt\",\r\n      \"key\": \"$certBase64\",\r\n      \"displayName\": \"CN=JitMigration\"\r\n    }\r\n  ],\r\n  \"tokenEncryptionKeyId\": \"\"\r\n}\r\n\"@\r\n\r\n$KeyCredentials = ($KeyCredentials | ConvertFrom-json)\r\n$KeyCredentials.tokenEncryptionKeyId = $KeyCredentials.keyCredentials[0].keyId\r\n\r\n$extApp = Invoke-MgGraphRequest -Method GET -Uri \"https:\/\/graph.microsoft.com\/v1.0\/applications?`$filter=displayName eq '$customExtensionAppName'\"\r\nInvoke-MgGraphRequest -Method PATCH -Uri \"https:\/\/graph.microsoft.com\/v1.0\/applications\/$($extApp.value.id)\" -Body ($KeyCredentials | ConvertTo-Json -Depth 10)\r\n<\/code><\/pre>\n","protected":false},"excerpt":{"rendered":"<p>Migrating to Entra Enternal ID from another identity provider brings with it the problem of how do you migrate the users&#8217; passwords. In the best of worlds, only the end users should know their passwords, which then becomes a migration problem. The solution has been around for a while that the new identity provider delegates [&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,1],"tags":[461,462],"_links":{"self":[{"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=\/wp\/v2\/posts\/8006"}],"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=8006"}],"version-history":[{"count":7,"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=\/wp\/v2\/posts\/8006\/revisions"}],"predecessor-version":[{"id":8016,"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=\/wp\/v2\/posts\/8006\/revisions\/8016"}],"wp:attachment":[{"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=8006"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=8006"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.redbaronofazure.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=8006"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}