Type to search 66 articles.

    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

    • Microsoft Entra ID
    • Hybrid identity
    • Microsoft Graph
    • PowerShell
    • Troubleshooting

    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.

    1. Find both objects on-premises. Search the forest for the conflicting value across userPrincipalName, mail and proxyAddresses. Two objects will have it. A surprising number of these are a leaver and a new starter with the same name.
    2. 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.
    3. 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.
    4. Force a cycle and re-check: Start-ADSyncSyncCycle -PolicyType Delta on the Connect server, then run the report again.
    5. 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 proxyAddresses more 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