When attempting to migrate a Microsoft 365 organization from federated authentication to Password Hash Sync, there are a couple of gotchas that can impact how you manage certain accounts. These changes in authentication behavior determine whether you need to implement new workflows or business processes–changes surrounding expired accounts and accounts flagged to force password change on next logon.
Update: I’ve created an updated version of this workaround as an inbound sync rule along with a corresponding scheduled task. I would recommend using that configuration instead, since it allows you to preview the object in the metaverse as it will be delivered to Azure AD.
Background
With federated identity, authentication is processed via the on-premises identity system–typically an AD FS farm that is connected to Active Directory.
In the first of many sidebars for this post, I want to mention that there are several different federation solutions that work with Azure AD. The other two most common ones are PING (which can be configured and integrated via AAD Connect) and OKTA. PING deployments typically use AAD Connect to synchronize identity directly to Azure AD while OKTA has its own synchronization engine.
Anyway.
When a user attempts to log on to Azure AD via federation, a number of things are evaluated:
- Is the account password correct?
- Is the option to force a password change set?
- Is the account enabled?
- Is the account expired?
When migrating to password hash sync, there are several items that Microsoft identifies as changes in behavior that you need to be aware of.
From the guide:
Password expiration policy
If a user is in the scope of password synchronization, the cloud account password is set to Never Expire. Users can continue to sign in to cloud services by using a synchronized password that is expired in the on-premises environment. The cloud password is updated the next time the password is changed on-premises.
Account expiration
If your organization uses the
accountExpiresattribute as part of user account management, be aware that this attribute is not synchronized to Azure AD. As a result, an expired Active Directory account in an environment configured for password hash synchronization will still be active in Azure AD. We recommend that if the account is expired, a workflow action should trigger a PowerShell script that disables the user’s Azure AD account (use the Set-AzureADUser cmdlet). Conversely, when the account is turned on, the Azure AD instance should be turned on.User must change password at next logon
When the option “User must change password at next logon” is selected for an account, the password is not synchronized to Azure AD. In this case, the user needs to change the password on-premises to allow the new password to be synchronized. This can be done directly on a domain-joined device.
While I can’t address all of the caveats, there are some things we can do.
Account expiration
Account expiration is tracked via the accountExpires attribute in Active Directory:

The property is a large integer stores the date value in FILETIME syntax. And what is FILETIME, you ask?
I’m so glad you did, because now it’s time for a story.
FILETIME is one of the systems of record for describing computing dates. It all starts with a time machine and traveling back to the adopting of the Gregorian calendar in 1582. The calendar, based on a 400-year cycle (called an epoch), “restarted” on January 1, 1601 and ran through December 31, 2000. Windows records time in 100-nanosecond intervals (called ticks) elapsed since the beginning of this epoch. This beginning is sometimes referred to as the zero date.
Modern computing began after the Gregorian calendar was introduced (but before the start of the second epoch), and somewhere along the line, everyone just generally agreed to keep on counting rather than starting over. The accountExpires value is essentially the number of nano-second intervals elapsed since January 1, 1601 12:00:00AM.
Other systems (and even some applications) have different zero dates as a baseline, but are generally counted the same. For example:
- The UUID epoch in RFC4122 starts October 15, 1582 –the first day Pope Gregory XIII switched the world away from the Julian calendar. The UUID epoch nearly 18 years before the Windows epoch begins (or -5748192000000000 ticks, if you’d rather count that way).
- Microsoft Excel originally used December 30, 1899 as its zero day. Because of a bug with Lotus 1-2-3 incorrectly calculating 1900 as a leap year, Microsoft chose to use the same incorrect calculation as Lotus to ensure cross-application workbook compatibility.
- Microsoft later changed the official zero date to January 1, 1900.
- Interestingly, Microsoft Excel also recognizes a zero date of January 1, 1904, due to early compatibility problems with Mac systems.
- Microsoft SQL Server uses January 1, 1900 as its epoch beginning.
- Chrome and Webkit also use January 1, 1601 as their epoch start date, but count in microseconds instead of nanoseconds.
- The Unix epoch starts January 1, 1970. It originally started on January 1, 1971 and was originally counted using 1/60th of a second intervals. The largest number a 32-bit system can represent is (2^31)-1. Later, the Unix epoch counting was revised to 1-second intervals. The 32-bit space for the Unix epoch runs out in on January 19, 2038 at 3:14:08 AM.
If you’ve ever wondered why those numbers pop up periodically in computing, now you know.
There are some interesting things to know about this that I discovered while working with Ric Poplin. (I’ve updated the corresponding documentation at Microsoft Learn to reflect this lifecycle of the accountExpires attribute).
- When an account is initially provisioned, the
accountExpiresvalue is set to9223372036854775807, which correlates to the radio button Never on the Account tab of an AD user object’s property sheet under the expiration section. This value isn’t technically a date, as it represents the signed 64-bit integer limit. If it were a date though, using a Unix epoch calculator, this value evaluates to the April 11, 2262 (in nanoseconds) or somewhere in the year 29,228 if you’re using a 64-bit epoch date. In either case, it’s not exactly never, but it’s not a date anyway. - If an account is configured with an expiration date, the original value in
accountExpiresis set to the corresponding FILETIME value of the expiration date. - If an account is later configured to Never expire, instead of reverting back to
9223372036854775807, theaccountExpiresvalue is set to0.
I digress.
For us normal humans, the well-known Windows epoch format can be converted easily to a [datetime] value.
[datetime]::FromFileTime([Int64]::Parse(((Get-AdUser <sAMAccountName> -Properties accountExpires).accountExpires)))

As a large integer, this is relatively easy to calculate against. When evaluating an account expiration time, we can simply compare it against the current time retrieved during the AAD Connect synchronization task runs using this pseudo-logic:
If accountExpires is greater than (later than) Now, then the account hasn’t expired.
Easy peasy, lemon squeezy.
Force password change on next logon
When you select the User must change password at next logon box (either through the Account tab of ADUC or through during the Reset User Password workflow), the on-premises pwdLastSet attribute (also stored a large integer) is reset to January 1, 1601.
[datetime]::FromFileTime([Int64]::Parse(((Get-AdUser <sAMAccountName> -Properties pwdLastSet).pwdLastSet)))

This is easy to detect on-premises with AAD Connect, but we’re really limited in what we can do with that platform. There are a few things we can do:
Cloud disable
Similar to disabling a cloud account for an on-premises account that has expired, we can craft a rule that disables the cloud account when “Force change password” is set on-premises. Not ideal, but it’s a potential in-box solution depending on your organization’s requirements.
Enabling ForcePasswordChangeOnLogOn
The much better option for dealing with forced change passwords is to use a newly supported parameter that flips the bit in Azure to enforce the native password reset authentication flow.
Password expiration
This is the most difficult problem (which is why I saved it for last). There’s nothing in the box that will technically work for it in the exact same way. Back in the olden days, the userAccountControl attribute had a number of flags that you could use to determine the state of an account. You can learn more about the property flags here (https://msdn.microsoft.com/en-us/library/ms680832(v=vs.85).aspx) and here (https://msdn.microsoft.com/en-us/library/aa772300(v=vs.85).aspx).
However, with modern releases of Windows, this attribute is computed real-time when the user properties are retrieved by evaluating the pwdLastSet timestamp in conjunction with a password policy. This is great to get the up-to-the-minute detail for account properties, but it’s not so great for state-based applications like AAD Connect since they import user and group attributes into a database table and then perform their comparisons and operations on these static values.
I did, however, create a blog post several years ago that presents a method for manually working through this (which involves a series of scheduled scripts and AAD Connect Sync rules). To learn about this solution, go here.
With that being said, let’s get rolling on the first two.

Disabling expired accounts in-cloud
First, we’ll look at fixing the whole account expires thing. This is likely a good business workflow, but you’ll want to get sign-off from the powers that be to ensure that it fits in with your policies.
Updating AAD Connect Attribute Sync
First thing’s first–we’ve got to make sure AAD Connect is configured to even know about the relevant attribute (accountExpires).
- On the desktop of the server running AAD Connect, launch the AAD Connect setup wizard.
- Click Configure to suspend the AAD Connect scheduler and enter setup.

- Select Customize synchronization options and click Next.

- Enter an admin credential for Azure AD (global administrator or hybrid identity administrator) and click Next.
- On the Connect your directories page, click Next.
- On the Domain and OU filtering page, click Next.
- On the Optional features page, select Directory extension attribute sync and click Next.

- On the Directory extensions page, select accountExpires (user) [LargeInteger] from the Available attributes column and click the > arrow to add it to the Selected Attributes column. Click Next.

- On the Ready to configure page, click Configure.

- After the process is complete, on the Configuration complete page, click Exit.
What is going on here, anyway?
In essence, you’ve told AAD Connect to now include the accountExpires attribute in its import and synchronization configuration. This makes the attribute available in the metaverse (MV).
AAD Connect will create a new MV attribute (extension_accountExpires) that can be used in synchronization rules. Yippee!

You can also check this with the following script:
Import-Module ADSync
$InToCS = @{ }
$OutToCD = @{ }
# Retrieve inbound user sync rules and build a dictionary of Attributes
$InboundRules = Get-ADSyncRule | ? { $_.Name -like '*In from AD - User*' } | % { $_.AttributeFlowMappings | Select-Object -Property Source, Destination }
($InboundRules | Sort-Object -Property Source | Get-Unique -AsString) | % {
If ([string]$_.Source -ne '' -and ([string]$_.Source).IndexOf(" ") -le 0 -and -Not $InToCS.Contains([string]$_.Source)) {
$InToCS.Add([string]$_.Source, [string]$_.Destination)
}
}
# Retrieve all outbound sync rules to the AAD Connected Directory
$OutboundRules = Get-ADSyncRule | ? { $_.Name -like '*Out to AAD - User*' } | % { $_.AttributeFlowMappings | Select-Object -Property Source, Destination }
($OutboundRules | Sort-Object -Property Source | Get-Unique -AsString) | % {
If (-Not $OutToCD.Contains([string]$_.Source)) {
$OutToCD.Add([string]$_.Source, [string]$_.Destination)
}
}
# Create a table mapping inbound and outbound attributes
$InOut = [System.Collections.ArrayList]@()
$InToCS.Keys | % {
$InOutObject = [PSCustomObject]@{
AD = $_
Metaverse = $InToCS[$_]
AAD = $OutToCD[$InToCS[$_]]
}
$InOut += $InOutObject
}
# List the MV extension attributes
$InOut | Sort-Object -Property AD | ? { $_.Metaverse -like "*extension_*" }
This will show you that the accountExpires attribute is mapped to the new custom MV attribute extension_accountExpires.

Updating sync configuration
Now that you’ve gotten AAD Connect configured, it’s time to update the synchronization rules. Synchronization rules do the heavy lifting, whether it’s passing an on-premises attribute through or transforming it before sending it on its way.
Synchronization rules can process data as it’s coming into the MV or as it’s leaving to a connected system. Where you do it depends largely on how many connected directories you’re manipulating objects as well as where you want to see the values reflected–some administrators like to craft rules on import flows so they can see the correct values in the metaverse. In other instances, if you need to apply a standard setting to all objects going to a particular directory, it may be easier to create a single outbound rule. You need to create the rules for each affected data source in whichever direction you’re going.
In this example, I want it to apply to all objects from all source directories (currently two forests) that will be connected to my tenant. Since we’re manipulating objects in two on-premises directories the same way and we’re sending all of the data to Azure AD, the simplest plan would be to perform the transformations outbound to Azure AD.
Disabling the sync schedule
Before performing any work on synchronization rules, it’s important to disable the AAD Connect sync schedule. Any time a rule is modified, a full synchronization is scheduled. While that in itself isn’t a bad thing, you don’t want AAD Connect to start processing accounts when you’re in the middle of performing a configuration.
To stop the sync scheduler:
- On the server running AAD Connect, launch an elevated PowerShell prompt.
- Run:
Set-ADSyncScheduler -SyncCycleEnabled $false - Confirm that the schedule is stopped by running
Get-ADSyncScheduler.

Alright. Carry on.
Creating a new rule to disable expired accounts
This process will guide you through creating a rule to disable expired accounts. To do this:
- Launch the Synchronization Rules Editor.
- Under Direction select Outbound and then click Add new rule.
- On the Description page, configure the rule with the following settings:
– Name: Out to AAD – Disable Expired Account
– Description: [ whatever you’d like ]
– Connected System: [ directory ending in onmicrosoft.com ]
– Connected System Object Type: user
– Metaverse Object Type: person
– Link Type: Join
– Precedence: [ whatever you’d like, lower than the lowest current rule. It’s best to leave some space between rule sets in case you need to add new ones later. ]
– Enable Password Sync: [ clear ]
– Disabled: [ clear ]

- Click Next.
- On the Scoping filter page, click Add clause and configure it with the following values:
– Under Attribute, select accountEnabled.
– Under Operator, select EQUAL.
– Under Value, enterTRUE. - Click Add clause and configure it with the following values:
– Under Attribute,select extension_accountExpires
– Under Operator, select NOTEQUAL
– Under Value, enter0. - Click Add group to create a new scoping group.
- Inside the new scoping group, click Add clause and configure it with the following values:
– Under Attribute, select accountEnabled.
– Under Operator, select EQUAL.
– Under Value, enterTRUE. - Inside the new scoping group, click Add clause and configure it with the following values- Under Attribute,select extension_accountExpires
– Under Operator, select NOTEQUAL
– Under Value, enter9223372036854775807. - Review the scoping configuration:

The purpose of this scoping filter is to ensure this rule only operates against accounts that are enabled on-premises. Expired accounts, from AD’s perspective, are still enabled–the directory just denies logon based on the value in theaccountExpiresattribute. Since the rule will be used to disable and enable accounts, we need to make sure we’re not accidentally enabling cloud accounts for users with disabled on-premises accounts. This scoping rule will scope out accounts that have never had theiraccountExpiresproperty set, but will scope in accounts that have had it modifed and then set to Never Expires. I’m working on that. - Click Next.
- On the Add join rules page, click Next (without adding any join rules).
- On the Transformations page, click Add transformation and configure it with the following values:
– Flowtype: Expression
– Target Attribute: accountEnabled
– Source:IIF(CNum([extension_accountExpires])=0,True,(IIF(CNum([extension_accountExpires])=9223372036854775807,True,IIF(DateFromNum([extension_accountExpires])>(CDate(NumFromDate(Now()))),True,False))))– Apply Once: [ clear ]
– Merge Type: Update - Click Save.
When read from left to right, this transform rule performs the following operations:
- Convert the value in
extension_accountExpiresto a number. - Check to see if the value in
extension_AccountExpiresis 0. - If it’s zero, then set
accountEnabledtoTRUEand we’re done. If it’s false, though…Go to step 4. - Check to see if the value in
extension_accountExpiresis9223372036854775807, which doesn’t exactly translate to a real year (it’s instead the maximum value of a 64-bit signed integer). If it were an actual date, it translates to the year 29,228 (probably safe to say it’s way past the expiration date of the human logging in). - If the value is
9223372036854775807, then flow the valueTRUEtoaccountEnabledin Azure AD and we’re done. If the value is something different, go to the next function. - Convert the value from
extension_accountExpiresinto a date and then check to see if it’s greater than the value of the functionNow(which retrieves the current system time at the execution of the synchronization rule). If the value ofextension_accountExpiresis greater thanNow, then flow the valueTRUEtoaccountEnabledin Azure AD, sinceaccountExpiresis in the future. If the value ofextension_accountExpiresis less thanNow, then flow the valueFALSEtoaccountEnabledin Azure AD, sinceaccountExpiresis in the past.
As always, test. 🙂
Testing
Make a change to a few on-premises accounts test accounts that are in synchronization scope (ideally, these accounts would all be in the same OU so you can easily locate them in the Synchronization Service application).
- Configure account 1 as an AD- enabled account with an account expiration date in the past. Expected result: AAD Connect should flow
FALSEto Azure AD for theaccountEnabledproperty - Configure account 2 as an AD-enabled account with an account expiration date in the future. Expected result: AAD Connect should flow
TRUEto Azure AD for theaccountEnabledproperty. - Configure account 3 as an AD-disabled account. Expected result: AAD Connect should flow
FALSEto Azure AD for theaccountEnabledproperty. - Configure account 4 as an AD-enabled account and set the option for User must change password at next logon. Expected result: AAD Connect should configure the account to prompt for password change when logging in from Azure AD.
- Configure account 5 as an AD-enabled account with an account expiration date in either the past or the future. After saving the object, go back and edit it to set the account expiration radio button to Never expire. Expected result: AAD Connect should flow
TRUEto Azure AD for theaccountEnabledproperty.
You’ll want to make sure that you’ve updated the appropriate property on each account. AAD Connect is stateful and it monitors the directory changes. If you selected accounts that already had those properties, it may be harder to locate them or verify that the new data has been imported into AAD Connect and is being computed properly.
Once that’s done, it’s time to test inside of AAD Connect.
- Launch the Synchronization Service.
- Select the Connectors tab.
- Select the connector that represents the on-premises directory where you made the account updates.
- In the Actions pane, select Run.

- On the Run Connector window, select the Delta Import run profile and click OK.

- After the State column has returned to Idle, select Search connector space in the Actions pane.
- Search for your test object.
If all of your test objects are in the same OU, select Sub-Tree as the Scope and then enter the distinguished name for the OU (OU=Accounts,DC=domain,DC=com format). Click Search.
If your test objects are in different OUs, select RDN as the Scope and then enter CN=<Account Name>. Click Search.

- Select your first object from the list and then click Preview.
- In the Preview window, select the Full synchronization radio button and then click Generate Preview. The status should report Synchronization successful. If you have a syntax error with your new synchronization rule, you’ll have to double-check it.

- Expand Connector Updates.
- Expand CN={GUID}, which is the Azure AD connected object and select Export Attribute Flow. Locate the accountEnabled attribute and verify that it has changed per your requirements.

- Click Close and repeat for the other test objects.
When you are satisfied that it is working, you can re-enable the scheduler.
Re-enabling the sync scheduler
Enabling the AAD Connect synchronization scheduler is just following the reverse process.
- Launch an elevated PowerShell window.
- Run
Set-ADSyncScheduler -SyncCycleEnabled $true
Since you’ve changed the synchronization rules, AAD Connect will automatically execute the correct import and synchronization tasks on its next run.
That’s it! You’re ready to disable stuff automatically!
Processing accounts with Force Change Password selected
As outlined previously, there are two ways to handle this problem: one, use the newer AAD Connect feature to flip the ForceChangePassword property in Azure AD and the older “disable everything” approach.
Force change in the cloud (recommended)
There’s a little-known feature that sat in preview for close to 4 years that quietly appears to have made its way into production. One the AAD Connect Server, you can check out the Get-ADSyncAADCompanyFeature cmdlet:

Yes, the ForcePasswordChangeOnLogOn parameter does almost exactly what you think it does. Almost, becuase it’s not perfect. But, it’s probably as close as we’ll see.
To enable the feature, launch an elevated PowerShell prompt on the server with AAD Connect and run:
Set-ADSyncAADCompanyFeature -ForcePasswordChangeOnLogOn $true

After enabling this option, AAD Connect to flip the ForceChangePassword property on the cloud object if you reset the user’s password and set the User must change password at next logon option at the same time:

After the password hash sync replication cycle (targeted at 2 minutes, but typically much faster), when you log in to the modified user’s account with the updated password value, you’ll get the native password change auth flow.

The bonus is if you’re using SSPR, it will reset the on-premises password.
Cool, cool.
Disable accounts with Force Change Password selected (not recommended)
As I mentioned before, until recently, there was not an in-the-box way to flip the change password bit on an account in Azure AD. One of the methods we have had available, though, is disabling the account. With the advent of the newer ForceChangePasswordOnLogon option, I’d recommend against this option. I’ve documented it, though, in case your organization wants to run this method.
This may or may not fit with your business processes. If your organization uses Azure AD self-service password reset (SSPR), this will prevent accounts that have had the “Force user to change password on next logon” selected on-premises from being able to use SSPR.
Users can still process the forced password change from domain-joined machines that are on-network. However, if that user is off-network, they will not be able to use SSPR until they connect their machine to the domain, making this a less-than-ideal solution.
If that scenario is ok, then let’s go!
- Launch the Synchronization Rules Editor.
- Under Direction select Outbound and then click Add new rule.
- On the Description page, configure the rule with the following settings:
– Name: Out to AAD – Disable accounts with passwords set to ForceChange
– Description: [ whatever you’d like ]
– Connected System: [ directory ending in onmicrosoft.com ]
– Connected System Object Type: user
– Metaverse Object Type: person
– Link Type: Join
– Precedence: [ whatever you’d like, lower than the lowest current rule, including the previously configured Disable Expired Account rule. ]
– Enable Password Sync: [ clear ]
– Disabled: [ clear ]

- Click Next.
- On the Scoping filter page, click Add clause and configure it with the following values:
– Under Attribute, select accountEnabled.
– Under Operator, select EQUAL.
– Under Value, enterTRUE.
Just like the previous rule, this one needs to be scoped to on-premises users whose accounts are currently enabled to prevent accidentally enabling an explicitly disabled on-premises account. - Click Next.
- On the Add join rules page, click Next (without adding any join rules).
- On the Transformations page, click Add transformation and configure it with the following values:
– Flowtype: Expression
– Target Attribute: accountEnabled
– Source:IIF(CStr([pwdLastSet])="16010101000000.0Z",False,True)
– Apply Once: [ clear ]
– Merge Type: Update - Click Save.
When read from left to right, this transform rule performs the following operations:
- Convert the value in
pwdLastSetto a string. - Check to see if the value in
pwdLastSetis16010101000000.0Z, which is the beginning of the Windows epoch. - If the value is
16010101000000.0Z, then flow the value FALSE toaccountEnabledin Azure AD. If the value is something different, then flow the value TRUE toaccountEnabledin Azure AD.
After making these sync rule changes, I’d recommend the following test steps to ensure things are working correctly.
BONUS
If you don’t want to configure the rules manually (or want to do it for a lot of AAD Connect deployments), you can use the scripting that I built.
2023-02-13 Note: If you previously downloaded this script, you may want to delete the old sync rule and deploy this one, since it does have significant changes in the way it works with accounts that had previously been set to expire.
Install-Script New-AADConnectRule-DisableExpiredAccts
Then, run the script:
New-AADConnectRule-DisableExpiredAccts
There are two parameters that can be used. From the help:
.PARAMETER BlockExpiredAccounts
This option sets a cloud account to disabled (BlockCredential:$true) if the corresponding on-premises account has expired. This parameter is enabled by default. Set this parameter to $false to skip configuring this rule.
.PARAMETER BlockForceChangePassswordAccounts
This option sets a cloud account to disabled (BlockCredential:$true) if the corresponding on-premises account is flagged for immediate password reset. If your organization uses Microsoft 365 Self-Service Password Reset (SSPR), enabling this option will prevent users forced to change password on next logon from using SSPR. Accounts with BlockCredential:$true are ineligible to use SSPR. This parameter is disabled by default. Set this parameter to $trueto configure this rule.


