This article is the practical part of the Theory – for the answer to the question "What’s the purpose of all this?", please refer to that Article.
To ensure I’m not seen as someone who says "someone should" and then does nothing, here follows a (by my standards) "quick" proof of concept on how password rotation could be handled in reality. As a brief reminder, here are the components we need for a solution:
- We set initial passwords, that our PAM solution is aware of
- The PAM solution can rotate passwords on its own when necessary / desired
- Users whose passwords are out of sync with the PAM solution can be realigned / reconciled
I will provide a PoC (Proof of Concept) snippet in PowerShell for each of the individual points – the specific implementation depends on the nuances of the PAM solution and the requirements of your organization.
A better approach would certainly be to delegate necessary permissions to an Azure Function that only rotates passwords – but not everyone has access to Azure resources, so the solution described here is purely PowerShell.
For full transparency, the approach described here is better than using Global Admin / Privileged Auth Admin on an App Registration, but it has a major drawback:
In my PoC, the PAM solution would authenticate itself using the less secure OIDC ROPC Auth Flow as a user to rotate its "own" password. This is necessary to avoid interactive prompts – however, OIDC does not actually want systems to authenticate directly as users
Result:
- The script only works if Conditional Access does not require MFA or a Compliant Device (usually via App + IP Exclusion – obviously not optimal)
- Alternatively, the script would need to be adapted to use an interactive flow (
or similar) and navigate the login process with the help of for example AutoIT
Connect-MgGraph
With this wall of caveats, one might ask why I choose this path at all: simply put, because I already have most of the snippets, and I just need to piece them together.
^ This sentence was written before my ROPC Detour 🤡
Set intial Passwords to a "known" value
Right off the bat, we need to find how the PAM solution gets the initial password. I want to avoid transmitting passwords where no transmission is necessary – in my quick example, a sufficiently privileged administrator (or the identity governance solution) will set a password on user creation, that the PAM solution can calculate as well.
For hopefully obvious reasons, setting the same password for all users is not an option. The most obvious solutions to me are hashing the username with a shared salt value or encrypting it with the same key.
This way, both parties always arrive at the same value for a given user, which can then be used as the password. The value is different for each user and there is no predictable system, even if a password were to leak at some point.
I would use the shared hash if I need to exchange or retrieve a symmetrical value. Windows has built-in hashing methods designed for passwords.
I would opt for encryption if there is an interface through which I can outsource cryptographic functions – for example, an HSM (Hardware Security Module) with the appropriate API – but I don’t have access to such a system, so I stick with hashing. This is not guidance based on experience of any kind, rather just a gut feeling.
To generate our passwords, we use the following function:
Since in our case the hash is equivalent to the password, the function is slightly hardened – both the hash and the salt are Secure Strings, and plaintext is cleared from memory as quickly as possible – see the immensely interesting work by Mark Kraus
function New-Pbkdf2Hash {
param (
[Parameter(Mandatory = $true, Position = 0)]
[ValidateNotNull()]
[string]$toHash,
[Parameter(Mandatory = $true, Position = 1)]
[ValidateNotNull()]
[Securestring]$salt,
[Parameter(Mandatory = $false)]
[ValidateSet("SHA256","SHA3_256","SHA384","SHA3_384","SHA512","SHA3_512")]
[string]$hashAlg = "SHA512",
[Parameter(Mandatory = $false)]
[ValidateSet("lower","upper")]
[string]$standardCase, # In case we want to bypass the inherent case sensitivity if manual work or possibly differing data sets are involved
# If we were to use this to function to generate hashes meant for storage, 210.000 iterations are recommended - but then calculation takes a few seconds
# In our case we are not storing a password, but generating one, so we stick with a lower value.
[Parameter(Mandatory = $false)]
[int]$iterations = 1000,
[Parameter(Mandatory = $false)]
[int]$hashLength = 60
)
switch ($standardCase) {
"lower" { $toHash = $toHash.ToLower() }
"upper" { $toHash = $toHash.ToUpper() }
}
try {
# Generating the Hash / Password and storing in a Byte Array
$saltBytes = [Text.Encoding]::UTF8.GetBytes([System.Net.NetworkCredential]::new("", $salt).Password)
# The dedicated Pbkdf2 method is only available in the full .NET, not .NET Framework
$hashResult = [System.Security.Cryptography.Rfc2898DeriveBytes]::new($toHash, $saltBytes, $iterations, $hashAlg)
$hashString = ConvertTo-Securestring "$( [System.Convert]::ToBase64String($hashResult.GetBytes($hashLength)) )" -AsPlainText -Force
}
catch {
Throw $_.Exception.Message
}
finally {
# Remove critical values from memory
$sensitiveVars = @("saltBytes","hashResult")
Remove-Variable $sensitiveVars
[gc]::Collect()
}
return $hashString
}
Now that we have our function for generating passwords, we need to agree on the parameters of the hash function. For my PoC, I assume that the hash is periodically exchanged between PAM and Entra ID / Identity Governance Admins.
I store the value on the corresponding hosts with
ConvertTo-SecureString "<ThisIsOurSharedHashValue>" -AsPlainText -Force | ConvertFrom-SecureString | Out-File .\encryptedHash.txt
This way we at least have minimal security. Caution – Remember that the export must be done with the same account that will be used for execution – otherwise, decryption will fail.
The salt value should ideally be stored in a securely accessible shared location and rotated regularly – most PAM solutions also have Secret Management APIs that are suitable for this purpose.
The Identity Provider / Entra Admin should now set the password for users as follows when creating user accounts:
Connect-MgGraph -Scopes 'User.ReadWrite.All'
# Importing the salt
$salt = Get-Content .\encryptedHash.txt | ConvertTo-SecureString
$upn = "max.mustermann@test.de"
# We assume manual work on both sides, so we agree on using Lowercase
$sharedPassword = New-Pbkdf2Hash -toHash $upn -salt $salt -standardCase lower
# We need to ensure that the user does not have to change the password at the next login, otherwise the ROPC flow will not work
# Therefore, forceChangePasswordNextSignIn = $false
$params = @{
displayName = "Max Mustermann"
passwordProfile = @{
Password = [System.Net.NetworkCredential]::new("", $sharedPassword).Password
forceChangePasswordNextSignIn = $false
}
accountEnabled = $true
mailNickName = "max.mustermann"
userPrincipalName = $upn
#...
}
New-Mguser @params
# Invoke-MgGraphRequest POST "/v1.0/users" -body $params
Remove-Variable "params"
[gc]::collect()
If everything worked out, we will receive a response with the newly created user object, but that’s not relevant right now.
The PAM solution can now rotate the password, provided it knows the salt and the user has been created in the vault.
Password rotation
Prerequisites
- Entra ID App Registration with Delegate "Directory.AccessAsUser.All" permissions and associated:
- Tenant ID
- Client ID
- Thumbprint of the certificate on the PAM Server or a secret
- If Applicable: Conditional Access exception for the Enterprise App (Reminder: if no Public Clients are activated in the registration, only our server with the certificate can use the app)
- Outgoing connection from the PAM server to Microsoft 365 addresses (at least ID 56)
- Microsoft.Graph.Authentication PowerShell module on the PAM server
If we can log in as a user, and the app allows us to interact with the Graph API with all the user’s permissions, why don’t we just use this account to set passwords?
Then we would need to check / know in advance what permissions all our accounts have, in order to then use a single account as a service account.
I prefer implementing a general solution, that also works if you do not happen to have a highly privileged account in the vault. Moreover, these users should not have standing rights but should activate PIM Just in Time – with interactive MFA.
Execution
Caution: We use the function from above for hash or password generation and the Graph ROPC Authentication.
The same variables will continue to be used.
$sharedPassword = New-Pbkdf2Hash -toHash $upn -salt $salt -standardCase lower
$newPassword = "<Neues_Passwort>" # How this password is generated depends on the PAM solution
# To establish the connection, we need the username and password of the user whose password we want to rotate.
[PSCredential]$userCred = New-Object System.Management.Automation.PSCredential ($upn, $sharedPassword)
Connect-ROPCGraph -userCredentials $userCred -tenantId $conf.tenantID -clientId $conf.clientID -certificateThumbprint $conf.thumb
# We set our new password
$params = @{
currentPassword = $usercred.GetNetworkCredential().Password
newPassword = $newPassword
}
$res = Invoke-MgGraphRequest POST "/v1.0/me/changePassword" -Body $params
# Alternative with Microsoft.Graph.Users.Actions module:
# Update-MgUserPassword -UserId $userCred.Username -BodyParameter $params
Remove-Variable "params", "newPassword"
[gc]::collect()
After successful execution, we do not receive a response, but at the next login, we must use the new password.
Recovering PAM Management of Passwords (Reconciliation)
And again, if we used a dedicated Azure Function, this would not be an issue – but I have already committed to pure PowerShell. Maybe someday if someone is interested 🤷
In this variant, the list of problematic users must be provided to an Entra ID Administrator (User Administrator if it involves regular accounts, Privileged Authentication Administrator / Global Admin for privileged accounts), who then executes the following:
$toReconcile = @("UPN1", "UPN2") # Or load from CSV
Connect-MgGraph -Scopes 'User.ReadWrite.All'
# We again load our "Shared Secret" as salt value
$salt = Get-Content .\encryptedHash.txt | ConvertTo-SecureString
foreach ($upn in $toReconcile) {
$sharedPassword = New-Pbkdf2Hash -toHash $upn -salt $salt -standardCase lower
$params = @{
passwordProfile = @{
Password = [System.Net.NetworkCredential]::new("", $sharedPassword).Password
forceChangePasswordNextSignIn = $false
}
}
Invoke-MgGraphRequest PATCH "/v1.0/users/$upn" -Body $params
# Update-MgUser -UserId $upn -PasswordProfile @{forceChangePasswordNextSignIn = $false; password = [System.Net.NetworkCredential]::new("", $sharedPassword).Password}
Remove-Variable "params", "sharedPassword"
[gc]::collect()
}
! Password rotation by the PAM Solution should always occur as soon as possible after it is initially set
Summary
If you truly prioritize least privilege, you often don’t get the simplest solution. And I’ll openly admit that it’s not all that realistic to expect this PoC to be deployed in every environment.
My hope is more that some PAM provider or consultant stumbles upon this article and sees that it is possible, and takes it upon themselves to improve the application. Or perhaps some other Entra ID Administrator wonders if it’s really necessary to grant Privileged Authentication Administrator and can voice their concerns with a solid foundation.
And maybe someday we’ll get a built-in module, or a wizard, or whatever it might be – so that we don’t have to install unnecessarily large security risks into our environments.
Or maybe we get more granular Application Permission scopes from Microsoft.
I will not moderate comments and I do not want your email address; please participate in the discussion through my associated LinkedIn Post.
If you are interested in the things I do follow me on LinkedIn.
Translation assisted by ChatGPT