Type to search 66 articles.

    Practical engineering guidance

    Microsoft Entra hybrid join: reading dsregcmd instead of guessing

    Hybrid join failures are diagnosed by a tool that prints eighty lines of state nobody reads. A script that parses it, names the failing phase and checks the service connection point.

    Series: Microsoft Entra ID

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

    A device that will not hybrid join produces one of the least helpful symptoms in Windows: a Conditional Access policy that requires a compliant or joined device blocks the user, and the device itself reports nothing at all. The user says “it worked yesterday”. The device says nothing, because nobody asked it properly.

    dsregcmd /status asks it properly. The problem is that it answers with around eighty lines across six sections, most of which are irrelevant to the current failure, and the two lines that matter are in the middle.

    The problem: three booleans decide what the device thinks it is

    Every conversation about a join state should start with the same three fields from the Device State section:

    AzureAdJoined DomainJoined EnterpriseJoined Meaning
    YES NO NO Microsoft Entra joined (cloud only)
    YES YES NO Microsoft Entra hybrid joined — the target state
    NO YES NO Domain joined, not yet registered with Entra ID
    NO YES YES Registered with the on-premises device registration service

    EnterpriseJoined being YES on a device you expected to be hybrid joined means it registered against an on-premises federation service instead of Entra ID, which is a different problem with a different fix.

    Where the failure is recorded

    When automatic registration fails on a domain-joined device, dsregcmd /status grows a Diagnostic Data section containing a pre-join diagnostic. Two fields there end most investigations:

    • Error Phase — pre-check, discover, auth or join. This alone narrows the cause to a quarter of the problem space.
    • Client ErrorCode — the specific failure. 0x801c001d means the device could not read the registration service information from Active Directory: the service connection point.

    The phases fail for characteristic reasons. pre-check failures usually mean no line of sight to a domain controller. discover failures mean the service connection point is missing, misconfigured or unreadable, or the device cannot reach https://enterpriseregistration.windows.net or https://login.microsoftonline.com. auth and join failures mean the device got far enough to be refused, which is a different investigation — usually the user, the token, or the device object in the cloud.

    Two event logs corroborate this:

    • User Device Registration (Microsoft-Windows-User Device Registration/Admin): event 304 records a failure at the join phase with its error code; event 307 records failure to look up the registration service in Active Directory.
    • AAD (Microsoft-Windows-AAD/Operational): events 1006 and 1007 bracket a primary refresh token acquisition, with 1007 carrying the final error code. This is the log for “joined fine, but single sign-on does not work”.

    The script

    Read-only. It runs dsregcmd /status, parses it, applies the state matrix above, reads the relevant event logs and optionally checks the service connection point in Active Directory.

    <#
    .SYNOPSIS
        Reports Microsoft Entra device registration state and the reason a join failed.
    
    .DESCRIPTION
        Read-only. Parses dsregcmd /status into objects, determines the join state, extracts the
        failing phase and error code from the diagnostic section, and collects the recent
        registration and token events. Makes no changes to the device or the directory.
    
        Run as the signed-in user for user-context fields (PRT, NgcSet). Run elevated for the
        full diagnostic section. Neither dsregcmd /status nor this script alters registration
        state; dsregcmd /leave does, and this script never calls it.
    
    .PARAMETER EventHours
        How far back to read the registration and token event logs. Default 24.
    
    .PARAMETER CheckServiceConnectionPoint
        Also read the service connection point from Active Directory. Requires the
        ActiveDirectory module and line of sight to a domain controller.
    
    .PARAMETER ReportPath
        Optional JSON output path for attaching to a ticket.
    
    .EXAMPLE
        .\Get-HybridJoinState.ps1 -Verbose
    
    .EXAMPLE
        .\Get-HybridJoinState.ps1 -CheckServiceConnectionPoint -ReportPath C:\Temp\join.json
    
    .NOTES
        Read-only: this script makes no changes.
        Output contains the device identity, the signed-in user and tenant identifiers.
        Treat it as internal information and redact before sharing outside the organisation.
        DeviceAuthStatus is present only on Windows 10 21H1 and later.
    #>
    
    #requires -Version 5.1
    
    [CmdletBinding()]
    param(
        [ValidateRange(1, 720)]
        [int]$EventHours = 24,
    
        [switch]$CheckServiceConnectionPoint,
    
        [ValidateScript({ Test-Path -Path (Split-Path -Path $_ -Parent) -PathType Container })]
        [string]$ReportPath
    )
    
    $ErrorActionPreference = 'Stop'
    
    function ConvertFrom-DsregOutput {
        <#
            .SYNOPSIS
                Turns dsregcmd /status text into a hashtable of sections, each a hashtable of fields.
            .NOTES
                Section headers are delimited by lines of '+' characters; fields are 'Name : Value'.
                Values containing colons (URLs, times) survive because only the first colon splits.
        #>
        [CmdletBinding()]
        [OutputType([hashtable])]
        param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Text)
    
        $sections = @{}
        $current = 'Unknown'
    
        for ($i = 0; $i -lt $Text.Count; $i++) {
            $line = $Text[$i]
            if ($line -match '^\s*\+-+\+\s*$') {
                # The section name sits between two rules of '+' characters.
                if ($i + 1 -lt $Text.Count -and $Text[$i + 1] -match '^\s*\|\s*(.+?)\s*\|\s*$') {
                    $current = $Matches[1].Trim()
                    if (-not $sections.ContainsKey($current)) { $sections[$current] = @{} }
                }
                continue
            }
    
            if ($line -match '^\s*([A-Za-z][A-Za-z0-9 _\-\.\(\)/]*?)\s*:\s*(.*)$') {
                if (-not $sections.ContainsKey($current)) { $sections[$current] = @{} }
                $sections[$current][$Matches[1].Trim()] = $Matches[2].Trim()
            }
        }
    
        return $sections
    }
    
    function Get-JoinState {
        <#
            .SYNOPSIS
                Applies the documented device state matrix to the three join booleans.
        #>
        [CmdletBinding()]
        [OutputType([string])]
        param([Parameter(Mandatory)][AllowNull()][hashtable]$DeviceState)
    
        if ($null -eq $DeviceState) { return 'Unknown - no Device State section in dsregcmd output' }
    
        $yes = { param($name) ($DeviceState[$name] -eq 'YES') }
        $entra = & $yes 'AzureAdJoined'
        $domain = & $yes 'DomainJoined'
        $enterprise = & $yes 'EnterpriseJoined'
    
        if ($entra -and $domain) { return 'Microsoft Entra hybrid joined' }
        if ($entra -and -not $domain) { return 'Microsoft Entra joined' }
        if ($domain -and $enterprise) { return 'On-premises DRS joined' }
        if ($domain) { return 'Domain joined only - not registered with Entra ID' }
        return 'Not joined'
    }
    
    Write-Verbose 'Running dsregcmd /status.'
    $raw = & dsregcmd.exe /status 2>&1
    if ($LASTEXITCODE -ne 0) { throw "dsregcmd exited with code $LASTEXITCODE." }
    
    $sections = ConvertFrom-DsregOutput -Text @($raw)
    
    # dsregcmd omits whole sections depending on state and Windows version. Treat an absent
    # section as empty so a missing field reads as blank instead of ending the run.
    function Get-Section {
        param([Parameter(Mandatory)][hashtable]$Sections, [Parameter(Mandatory)][string]$Name)
        if ($Sections.ContainsKey($Name)) { return $Sections[$Name] }
        Write-Verbose "Section not present in dsregcmd output: $Name"
        return @{}
    }
    
    $deviceState = Get-Section -Sections $sections -Name 'Device State'
    $deviceDetails = Get-Section -Sections $sections -Name 'Device Details'
    $diagnostics = Get-Section -Sections $sections -Name 'Diagnostic Data'
    $sso = Get-Section -Sections $sections -Name 'SSO State'
    
    $state = Get-JoinState -DeviceState $deviceState
    Write-Verbose "Join state: $state"
    
    # The diagnostic section only appears when automatic registration has failed.
    $errorPhase = $diagnostics['Error Phase']
    $clientErrorCode = $diagnostics['Client ErrorCode']
    
    $phaseMeaning = switch ($errorPhase) {
        'pre-check' { 'No line of sight to a domain controller, or the device is not eligible.' }
        'discover' { 'Service connection point unreadable, or enterpriseregistration/login endpoints unreachable.' }
        'auth' { 'The device reached Entra ID and authentication was refused.' }
        'join' { 'Authentication succeeded and the join itself was refused.' }
        default { if ($errorPhase) { 'Unrecognised phase; read the Server ErrorCode and Server Message fields.' } else { 'No failed registration recorded.' } }
    }
    
    $events = [System.Collections.Generic.List[object]]::new()
    $since = (Get-Date).AddHours(-$EventHours)
    $logs = @(
        @{ Log = 'Microsoft-Windows-User Device Registration/Admin'; Ids = 201, 304, 305, 307 }
        @{ Log = 'Microsoft-Windows-AAD/Operational'; Ids = 1006, 1007 }
    )
    
    foreach ($log in $logs) {
        try {
            Get-WinEvent -FilterHashtable @{ LogName = $log.Log; Id = $log.Ids; StartTime = $since } -ErrorAction Stop |
                ForEach-Object {
                    $events.Add([pscustomobject]@{
                            TimeCreated = $_.TimeCreated
                            Log         = $log.Log
                            Id          = $_.Id
                            Level       = $_.LevelDisplayName
                            Message     = ($_.Message -split "`r?`n" | Select-Object -First 3) -join ' '
                        })
                }
        }
        catch [System.Diagnostics.Eventing.Reader.EventLogNotFoundException] {
            Write-Verbose "Log not present on this device: $($log.Log)"
        }
        catch {
            # "No events were found" is the normal, healthy case and is not an error.
            Write-Verbose "No matching events in $($log.Log): $($_.Exception.Message)"
        }
    }
    
    $scp = $null
    if ($CheckServiceConnectionPoint) {
        try {
            Import-Module ActiveDirectory -ErrorAction Stop
            $configurationNamingContext = (Get-ADRootDSE -ErrorAction Stop).configurationNamingContext
            $scpPath = "CN=62a0ff2e-97b9-4513-943f-0d221bd30080,CN=Device Registration Configuration,CN=Services,$configurationNamingContext"
            $keywords = (Get-ADObject -Identity $scpPath -Properties keywords -ErrorAction Stop).keywords
            $scp = [pscustomobject]@{
                Found      = $true
                TenantId   = ($keywords | Where-Object { $_ -like 'azureADId:*' }) -replace '^azureADId:', ''
                TenantName = ($keywords | Where-Object { $_ -like 'azureADName:*' }) -replace '^azureADName:', ''
            }
            Write-Verbose "Service connection point found for tenant $($scp.TenantName)."
        }
        catch {
            $scp = [pscustomobject]@{ Found = $false; TenantId = $null; TenantName = $null }
            Write-Warning "Service connection point not readable: $($_.Exception.Message)"
        }
    }
    
    $result = [pscustomobject]@{
        Device                 = $env:COMPUTERNAME
        CollectedUtc           = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
        JoinState              = $state
        AzureAdJoined          = $deviceState['AzureAdJoined']
        DomainJoined           = $deviceState['DomainJoined']
        EnterpriseJoined       = $deviceState['EnterpriseJoined']
        DeviceId               = $deviceState['DeviceId']
        DeviceAuthStatus       = $deviceDetails['DeviceAuthStatus']
        AzureAdPrt             = $sso['AzureAdPrt']
        AzureAdPrtExpiryTime   = $sso['AzureAdPrtExpiryTime']
        ErrorPhase             = $errorPhase
        ClientErrorCode        = $clientErrorCode
        ServerErrorCode        = $diagnostics['Server ErrorCode']
        Interpretation         = $phaseMeaning
        ServiceConnectionPoint = $scp
        Events                 = $events | Sort-Object TimeCreated -Descending
    }
    
    if ($ReportPath) {
        $result | ConvertTo-Json -Depth 5 | Set-Content -Path $ReportPath -Encoding UTF8
        Write-Verbose "Wrote $ReportPath"
    }
    
    if ($state -ne 'Microsoft Entra hybrid joined' -and $deviceState['DomainJoined'] -eq 'YES') {
        Write-Warning "Device is domain joined but not hybrid joined. Phase: $(if ($errorPhase) { $errorPhase } else { 'not recorded' }). $phaseMeaning"
    }
    if ($sso['AzureAdPrt'] -eq 'NO') {
        Write-Warning 'No primary refresh token. Single sign-on to cloud resources will fail even if the device is joined.'
    }
    
    $result

    Reading the result

    JoinState is hybrid joined and AzureAdPrt is NO. The join is fine; token acquisition is not. Look at the AAD log events 1006 and 1007, and at the Previous Prt Attempt fields in the SSO section. This is frequently a network or proxy problem affecting the user’s session rather than the device.

    ErrorPhase is discover and Client ErrorCode is 0x801c001d. The device cannot read the service connection point. Run again with -CheckServiceConnectionPoint: either it is missing, or the tenant it names is not the tenant you expect. In a multi-forest environment each forest containing domain-joined computers needs its own.

    DeviceAuthStatus reports the device is disabled or deleted. The cloud object was removed or disabled while the device kept its certificate. The device must re-register; the cloud object should not simply be re-enabled without understanding why it was disabled.

    Nothing in Diagnostic Data at all. Automatic registration has not been attempted recently. Confirm the device has line of sight to a domain controller and that the scheduled task for automatic device join has run.

    Why the script does not fix anything

    The remediation for a stuck registration — dsregcmd /leave, then re-register — signs the device out of cloud resources, discards the device certificate and the primary refresh token, and is disruptive to the person using it. It is also frequently unnecessary: a missing service connection point or a blocked endpoint is fixed centrally, and every affected device then recovers on its own.

    That is why this script reads and explains rather than acts. Fix the cause the report names, then let automatic registration run. Reach for /leave only when the report shows the device state itself is broken rather than the environment around it.

    Verification and limits

    The Device State matrix, the field names (AzureAdJoined, DomainJoined, EnterpriseJoined, DeviceAuthStatus, AzureAdPrt, Error Phase, Client ErrorCode), the meaning of 0x801c001d, the User Device Registration and AAD event log channel names, events 304, 307, 1006 and 1007, and the service connection point distinguished name and its azureADId and azureADName keywords were checked against current Microsoft documentation on 20 September 2026.

    I verified the parser against captured dsregcmd /status text covering hybrid joined, domain joined only and failed registration cases, confirming it returns the documented state for each. It was not run on a production device, and no output from a real device appears in this article. dsregcmd output differs between Windows versions: DeviceAuthStatus appears only on Windows 10 21H1 and later, and a field the parser does not find is returned as empty rather than causing a failure.

    I could not confirm the exact message text of event 305 in the User Device Registration log, so the script collects it without interpreting it. Microsoft also publishes a device registration troubleshooter tool; where it is available, run it alongside this.

    References