Self-service sign-up in Entra External ID user flows creates a real cost problem the moment a bot discovers the endpoint. A bot creates an account and triggers a phone or SMS verification step inside the flow, and the tenant gets billed for every one of those verification attempts. The bot has no interest in actually completing sign-up. Running up your Entra External ID verification bill is the entire point of the attack.
The fix Damien Bod documents is straightforward: switch off the self-service sign-up event on the user flow through Microsoft Graph, so unauthenticated visitors can no longer trigger verification at all. This does not touch existing users and does not break sign-in for anyone already using the tenant. It simply removes the door bots were walking through.
Why SMS and phone verification make the wrong front door
SMS and phone verification carry a real per-message cost, and that cost is exactly what makes them attractive targets for bots. Placing this kind of verification in front of an unauthenticated flow means anyone on the internet can trigger a billable event without proving who they are first. This is a general lesson beyond Entra External ID: any identity provider that charges per verification attempt needs that verification gated behind something that automation cannot casually trigger.
The better long-term fix is moving away from SMS and phone as a first factor or MFA option wherever possible. An identity platform that does not support passkeys or an authenticator app at minimum is worth reconsidering, because MFA that depends on per-use billing is MFA that can be weaponized into a cost attack. Passkeys and TOTP-based authenticator apps avoid this problem entirely since neither carries a per-verification charge.
Setting up the Graph app registration
Before running the script, you need an Azure App registration with the Graph application permission EventListener.ReadWrite.All granted, along with admin consent. You also need a client secret on that registration, plus the tenant ID and the application (client) ID. This app registration authenticates to Graph using the client credentials flow rather than a signed-in user, which is what lets the script run unattended.

Keep this app registration scoped tightly. It only needs the one permission, and there is no good reason to leave the credential lying around once the job finishes, a point worth returning to later.
The PowerShell script that disables sign-up
The script, written with help from Marc Rufer, needs PowerShell 7 or later along with the Microsoft.Graph.Authentication and Microsoft.Graph.Identity.SignIns modules at version 2.35.1 or above. It takes four parameters: the Entra External ID tenant ID, the application (client) ID of the user flow you want to modify, and the client ID and secret from the app registration you just created.
#Requires -Version 7.0
#Requires -Modules @{ ModuleName="Microsoft.Graph.Authentication"; ModuleVersion="2.35.1" }
#Requires -Modules @{ ModuleName="Microsoft.Graph.Identity.SignIns"; ModuleVersion="2.35.1" }
# Create an App registration for the client credentials flow
# Graph permission required: EventListener.ReadWrite.All
PARAM
(
[Parameter(Mandatory = $true, Position = 0, HelpMessage = "Id of the Entra External ID tenant")]
[string] $tenantId
,
[Parameter(Mandatory = $true, Position = 1, HelpMessage = "Application (Client) Id of the user flow")]
[string] $applicationId
,
[Parameter(Mandatory = $true, Position = 2, HelpMessage = "Client secret for the app registration")]
[string] $clientSecret
,
[Parameter(Mandatory = $true, Position = 3, HelpMessage = "Client Id for the app registration")]
[string] $clientId
)
$cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $clientId, (ConvertTo-SecureString -String $clientSecret -AsPlainText -Force)
Connect-MgGraph -TenantId $tenantId -Credential $cred
$response = Get-MgIdentityAuthenticationEventFlow -Filter "microsoft.graph.externalUsersSelfServiceSignUpEventsFlow/conditions/applications/includeApplications/any(appId:appId/appId eq '$applicationId')"
$userFlowId = $response.Id
$body = @{
"@odata.type" = "#microsoft.graph.externalUsersSelfServiceSignUpEventsFlow"
"onInteractiveAuthFlowStart" = @{
"@odata.type" = "#microsoft.graph.onInteractiveAuthFlowStartExternalUsersSelfServiceSignUp"
"isSignUpAllowed" = $false
}
}
Update-MgIdentityAuthenticationEventFlow -AuthenticationEventsFlowId $userFlowId -BodyParameter $body
The script connects to Graph with client credentials rather than an interactive login, which is what makes it suitable for a scheduled job instead of a one-off manual run. It queries Get-MgIdentityAuthenticationEventFlow, filtering for the self-service sign-up event flow whose included applications match the application ID you passed in. Once it has that flow’s ID, it calls Update-MgIdentityAuthenticationEventFlow and sets isSignUpAllowed to false inside the onInteractiveAuthFlowStart block, which is the setting that actually switches off self-service sign-up.
One thing worth watching here: the filter matches on application ID, so if more than one user flow in your tenant references the same application, check which flow ID actually comes back before assuming the update is scoped the way you expect. In a tenant with several user flows, run Get-MgIdentityAuthenticationEventFlow on its own first, without the update call, and look over the result before wiring up the full script.
Running the script against your tenant
With the app registration in place, running the script means supplying the four parameters and calling it.
$tenantId = "Entra-External-ID-tenant-id"
$appId = "Application-(Client)-ID-from-user-flow"
$clientSecret = "Azure-App-Registration-Client-Secret"
$clientId = "Azure-App-Registration-Application-(Client)-ID"
.\Disable-SignUpInExternalIdUserFlow.ps1 -tenantId $tenantId -applicationId $appId -clientSecret $clientSecret -clientId $clientId
If the parameters are correct, the update call returns without error and switches off sign-up on that flow immediately. There is no propagation delay to wait out here, since this hits the Graph API directly instead of going through the Azure portal UI.
Clean up the app registration after use
Once the script has run successfully, delete the Azure App registration created for it. Application permissions like EventListener.ReadWrite.All are powerful, and there is no reason to keep a credential with that scope sitting in your tenant after a one-time change is made. If you expect to run this again periodically, keep the registration instead of deleting it, but rotate its secret regularly and store it in a key vault rather than passing it around as a script parameter.
When it makes sense to automate this further
This script is built as a run-it-when-you-notice-abuse tool, which works fine for a single tenant you are actively watching. If you run a multi-tenant SaaS product on Entra External ID, one manual run per incident does not scale once you have more than a handful of tenants. A reasonable next step is wrapping this logic inside an Azure Function that runs on a schedule, checks each tenant’s user flows for unusual sign-up volume or verification spend, and disables sign-up automatically wherever it crosses a threshold you define.
That said, automating this comes with its own trade-off. Switching off self-service sign-up automatically also blocks legitimate new users during a genuine traffic spike, so any automated version of this needs a way to tell a real sign-up surge apart from a bot-driven one, whether through CAPTCHA-based bot detection, rate limiting per IP range, or watching phone verification failure rates rather than raw volume alone.
Leave a Reply