Practical engineering guidance
Directory synchronisation errors: duplicate attributes and soft matches that never resolve
A user exists on-premises, does not exist in the cloud, and sync reports success. A read-only triage script that finds the objects Entra ID quarantined and explains which attribute caused it.
Series: Microsoft Entra ID
The ticket says a new starter cannot sign in. The account exists in Active Directory, it is in
the right organisational unit, and the synchronisation cycle reports no failure. In the cloud
the account either does not exist, or exists with a user principal name nobody chose, ending in
four digits and onmicrosoft.com.
That placeholder is not corruption. It is Entra ID telling you it found the same value on two objects and refused to guess which one was right.
The problem: sync succeeds, the object does not
Duplicate Attribute Resiliency has been on by default since 2016 and cannot be turned off.
Before it existed, one duplicate userPrincipalName or proxyAddresses value failed an export
and, in the worst cases, stalled a batch behind it. Now the object is provisioned with a
quarantined attribute — the placeholder UPN — and the error is recorded against the object
rather than the sync run.
This is a large improvement in reliability and a small disaster in visibility. The run looks clean. The object looks present. Only the object’s own error collection says otherwise.
Where the evidence actually lives
Three places, and they show different things:
| Source | Shows | Good for |
|---|---|---|
onPremisesProvisioningErrors on the cloud object |
The attribute conflict, the value, when it happened | Scripted triage across the whole tenant |
| Entra Connect Health, object-level sync error report | Six error categories, side-by-side comparison, a guided fix | Investigating one object properly |
| The sync engine on the Connect server | Run history, connector state, scheduler | Whether sync is running at all |
The Graph property is the one that scales, and it is the one the script below uses. It is
also the narrowest: it reports a category of PropertyConflict, with
propertyCausingError of either UserPrincipalName or ProxyAddress, the conflicting
value, and occurredDateTime. Data-mismatch errors such as InvalidSoftMatch, and
validation failures such as a malformed UPN, are classified in Entra Connect Health rather than
exposed through this property — which is why the article does not claim the script finds
everything.
Connect Health needs Microsoft Entra ID P1 or P2. If you do not have it, the script plus the sync engine’s own run history is what you have, and it is enough for the common case.
Check that sync is running first
Before triaging objects, rule out the boring explanation. On the Entra Connect server:
# Is the scheduler enabled, when did it last run, and is this a staging server?
Get-ADSyncScheduler |
Select-Object SyncCycleEnabled, StagingModeEnabled, NextSyncCyclePolicyType,
NextSyncCycleStartTimeInUTC, CurrentlyEffectiveSyncCycleInterval
SyncCycleEnabled set to False explains everything and is often left that way after
maintenance. StagingModeEnabled set to True means this server is deliberately not exporting
anything — correct for a standby server, catastrophic if it is the only one.
If the cmdlet is not found, the module is not loaded in that session:
Import-Module ADSync. The cmdlets exist only on the Connect server itself.
The script
Read-only. It reads cloud users and groups and writes a CSV of objects Entra ID has flagged.
<#
.SYNOPSIS
Reports Microsoft Entra ID objects carrying on-premises provisioning errors.
.DESCRIPTION
Read-only. Finds users and groups whose onPremisesProvisioningErrors collection is
populated, and reports the conflicting attribute and value so the duplicate can be
resolved on-premises. Makes no changes to the tenant or the directory.
Requires an existing Microsoft Graph connection with read scopes:
Connect-MgGraph -Scopes 'User.Read.All','Group.Read.All'
Graph reports the PropertyConflict category, where the causing property is
UserPrincipalName or ProxyAddress. Other sync error classes (invalid soft match,
data validation failure, large attribute) are reported by Entra Connect Health and
will not appear here.
.PARAMETER ReportPath
CSV output path. The folder must exist.
.PARAMETER IncludeGroups
Also inspect groups. Group conflicts are rarer and usually involve proxy addresses.
.EXAMPLE
.\Get-DirectorySyncError.ps1 -ReportPath C:\Reports\sync-errors.csv -Verbose
.NOTES
Read-only: this script makes no changes.
Output contains user principal names and mail addresses. Treat as sensitive.
The fix is made on-premises, on the object that should NOT keep the value.
#>
#requires -Version 7.0
#requires -Modules Microsoft.Graph.Users
[CmdletBinding()]
param(
[ValidateScript({ Test-Path -Path (Split-Path -Path $_ -Parent) -PathType Container })]
[string]$ReportPath = (Join-Path $env:TEMP 'directory-sync-errors.csv'),
[switch]$IncludeGroups
)
$ErrorActionPreference = 'Stop'
if ($null -eq (Get-MgContext)) {
throw "Not connected. Run: Connect-MgGraph -Scopes 'User.Read.All','Group.Read.All'"
}
$findings = [System.Collections.Generic.List[object]]::new()
function Add-ProvisioningError {
param(
[Parameter(Mandatory)]$Object,
[Parameter(Mandatory)][string]$ObjectType,
[Parameter(Mandatory)][string]$Identifier
)
foreach ($syncError in @($Object.OnPremisesProvisioningErrors)) {
if ($null -eq $syncError) { continue }
$findings.Add([pscustomobject]@{
ObjectType = $ObjectType
DisplayName = $Object.DisplayName
Identifier = $Identifier
Category = $syncError.Category
PropertyCausing = $syncError.PropertyCausingError
ConflictingValue = $syncError.Value
OccurredUtc = if ($syncError.OccurredDateTime) {
([datetime]$syncError.OccurredDateTime).ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
}
else { 'unknown' }
QuarantinedUpnLike = $ObjectType -eq 'User' -and $Identifier -match '\d{4}@.+\.onmicrosoft\.com$'
})
}
}
Write-Verbose 'Reading users with on-premises provisioning errors.'
$userProperties = 'id', 'displayName', 'userPrincipalName', 'onPremisesProvisioningErrors', 'onPremisesSyncEnabled'
# Ask the service to return only objects carrying an error where it can; fall back to reading
# every synchronised user if the filter is rejected, which costs time but not accuracy.
$users = try {
Get-MgUser -All -Property $userProperties -Filter 'onPremisesProvisioningErrors/any()' -ConsistencyLevel eventual -CountVariable matched -ErrorAction Stop
}
catch {
Write-Warning "Server-side filter unavailable ($($_.Exception.Message.Split([Environment]::NewLine)[0])). Reading all synchronised users instead."
Get-MgUser -All -Property $userProperties -ErrorAction Stop |
Where-Object { $_.OnPremisesProvisioningErrors.Count -gt 0 }
}
foreach ($user in $users) {
Add-ProvisioningError -Object $user -ObjectType 'User' -Identifier $user.UserPrincipalName
}
if ($IncludeGroups) {
Write-Verbose 'Reading groups with on-premises provisioning errors.'
Get-MgGroup -All -Property 'id', 'displayName', 'mail', 'onPremisesProvisioningErrors' -ErrorAction Stop |
Where-Object { $_.OnPremisesProvisioningErrors.Count -gt 0 } |
ForEach-Object { Add-ProvisioningError -Object $_ -ObjectType 'Group' -Identifier $_.Mail }
}
$findings | Sort-Object OccurredUtc -Descending | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
Write-Verbose "Wrote $ReportPath"
if ($findings.Count -eq 0) {
Write-Verbose 'No objects are carrying on-premises provisioning errors.'
}
else {
Write-Warning "$($findings.Count) object(s) have provisioning errors. Resolve the duplicate value on-premises, then wait for the next sync cycle."
$findings | Group-Object PropertyCausing | ForEach-Object {
Write-Warning " $($_.Name): $($_.Count)"
}
}
$findings | Sort-Object OccurredUtc -Descending
Fixing a duplicate, in the right order
The report tells you the value that collided. It does not tell you which object deserves to keep it, and no script should decide that.
- Find both objects on-premises. Search the forest for the conflicting value across
userPrincipalName,mailandproxyAddresses. Two objects will have it. A surprising number of these are a leaver and a new starter with the same name. - Decide which one keeps it. This is a business question. The answer is often “the one that is not a disabled mailbox from 2019”, but confirm it.
- Change the other one on-premises, never in the cloud. A cloud edit on a synchronised attribute is overwritten at the next cycle, and you will have spent the afternoon proving it.
- Force a cycle and re-check:
Start-ADSyncSyncCycle -PolicyType Deltaon the Connect server, then run the report again. - Confirm the placeholder UPN is gone. Resiliency releases the quarantined value once the
conflict is resolved; if it does not, the conflict still exists somewhere you have not looked
— a mail contact and a mailbox share
proxyAddressesmore often than people expect.
When you make that on-premises change with a script, make it previewable. Discovery and action belong in separate steps, with a human between them:
# Step two, acting on an approved list only. -WhatIf shows every change before any is made.
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param([Parameter(Mandatory)][string]$ApprovedCsv)
Import-Csv -Path $ApprovedCsv | ForEach-Object {
if ($PSCmdlet.ShouldProcess($_.SamAccountName, "Remove proxy address $($_.ConflictingValue)")) {
Set-ADUser -Identity $_.SamAccountName -Remove @{ proxyAddresses = $_.ConflictingValue } -ErrorAction Stop
}
}
The harder cases this does not cover
Invalid soft match. A cloud object already carries a different immutable ID, so the on-premises object cannot claim it. This shows in Entra Connect Health rather than in the Graph property, and the remediation depends on which object is authoritative.
Hard match protections. From 1 July 2026 Entra ID applies additional protections to hard matching, producing errors where the target cloud account already has an on-premises object identifier set, or holds — or is eligible for — a privileged role. If a match that used to work now fails against a privileged account, check the current documentation before changing anything: the documented recovery involves temporarily removing the role assignment or eligibility, not forcing the match.
Blocked matching by design. BlockSoftMatchEnabled and
BlockCloudObjectTakeoverThroughHardMatchEnabled are tenant features that deliberately prevent
matching. If someone enabled them for a migration and never turned them off, every new match
fails and nothing in the object’s error collection explains why. Check
Get-MgDirectoryOnPremiseSynchronization before assuming an attribute problem.
Verification and limits
The onPremisesProvisioningErrors property and its fields (category, propertyCausingError,
value, occurredDateTime), the documented PropertyConflict category, the duplicate
attribute resiliency behaviour and placeholder UPN format, the Entra Connect Health error
categories and its P1/P2 licensing requirement, the ADSync cmdlets
(Get-ADSyncScheduler, Start-ADSyncSyncCycle), the tenant match-blocking features and the
July 2026 hard-match protections were checked against current Microsoft documentation on
20 September 2026.
The script was written for this article, statically analysed, and not executed against a
production tenant. The server-side filter on onPremisesProvisioningErrors is an advanced
query and may be rejected depending on tenant and property support, which is why the script
falls back to reading all synchronised users and filtering locally — correct either way, slower
on a large tenant.
I have not verified the exact least-privilege scope for reading this specific property; the script asks for the standard directory read scopes, which are sufficient in practice and grant no write access.
References
Reader feedback
Was this article useful?
No ratings yet. Be the first to rate this article.
