Type to search 66 articles.

    Practical engineering guidance

    Before you enforce LDAP signing: find the clients that will break

    Enforcing LDAP signing and channel binding is a five-minute change that breaks printers, scanners and appliances nobody documented. A script that finds them first, from the domain controllers' own evidence.

    Series: Security hardening

    • Active Directory
    • Security
    • Windows Server
    • PowerShell
    • Troubleshooting

    Unsigned LDAP binds are a standing credential-relay risk, and the remediation is a registry value on each domain controller. The change itself takes minutes.

    What takes weeks is the part nobody plans for: the multifunction printer that scans to a home folder, the badge system, the appliance whose LDAP configuration was set up by a contractor in 2017, and the line-of-business application whose vendor’s support line will ask what changed.

    The evidence needed to avoid that is already on the domain controllers. It just is not switched on.

    The two controls are independent

    They are commonly discussed as one change, and they are not:

    LDAP signing is controlled by LDAPServerIntegrity under HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Parameters. A value of 0 does not require signing; 2 requires it. The Group Policy equivalent is Domain controller: LDAP server signing requirements.

    LDAP channel binding is controlled by LdapEnforceChannelBinding in the same key: 0 never enforces, 1 enforces when the client supports it, 2 always requires it.

    Fixing one does not fix the other. A client can be perfectly compliant with signing and still fail channel binding, which is why enforcement projects that test only one control produce a second outage a fortnight after the first.

    Windows Server 2025 adds a separate value, LDAPServerEnforceIntegrity, behind its own policy, and it takes precedence over the older signing policy where both are configured. New Windows Server 2025 deployments require signing by default and default channel binding to “when supported” — but an in-place upgrade preserves the permissive settings it came with. A domain upgraded to 2025 is not hardened by that upgrade.

    The events that name the clients

    Domain controllers already count unsigned binds. Three events in the Directory Service log matter before enforcement:

    Event Meaning
    2886 Signing is not required; a reminder logged after the directory service starts
    2887 A 24-hour summary: how many unsigned binds were accepted
    2888 A 24-hour summary: how many unsigned binds were rejected (only once enforcing)
    2889 One event per unsigned bind, including the client IP address and the identity

    Event 2889 is the one that turns a project from guesswork into a list — and it is not logged until you raise the LDAP interface diagnostic level. That is the deliberate trade: Microsoft’s own guidance is to keep that level low normally and raise it temporarily, because it is verbose.

    For channel binding, events 3039, 3040 and 3041 record a client that did not provide a token, one whose token did not match, and one that bound successfully with channel binding.

    The script

    Read-only by default. -EnableClientLogging raises the diagnostic level so 2889 events are produced, and -DisableClientLogging puts it back; both support -WhatIf.

    <#
    .SYNOPSIS
        Reports LDAP signing and channel binding enforcement state and the clients still binding
        without them.
    
    .DESCRIPTION
        Read-only by default. Reads the enforcement registry values on a domain controller,
        reports the LDAP interface diagnostic level, and summarises the Directory Service
        events that count and identify unsigned binds and channel binding failures.
    
        -EnableClientLogging raises the LDAP Interface Events diagnostic level to 2 so that
        per-client event 2889 is written. This increases log volume: enable it, collect for a
        representative period including month-end and overnight batch windows, then use
        -DisableClientLogging to set it back to 0.
    
        Run on a domain controller, elevated. Run it on EVERY domain controller: clients bind
        to the one they discover, and a client that is invisible on one DC is busy on another.
    
    .PARAMETER EventDays
        How far back to read the Directory Service log. Default 14.
    
    .PARAMETER ReportPath
        Optional CSV path for the per-client list.
    
    .PARAMETER EnableClientLogging
        Raise the LDAP Interface Events diagnostic level to 2 (per-client logging).
    
    .PARAMETER DisableClientLogging
        Return the LDAP Interface Events diagnostic level to 0.
    
    .EXAMPLE
        .\Get-LdapBindClient.ps1 -Verbose
    
    .EXAMPLE
        .\Get-LdapBindClient.ps1 -EnableClientLogging -WhatIf
    
    .NOTES
        Read-only unless -EnableClientLogging or -DisableClientLogging is supplied, and those
        change only a diagnostic logging level, never an enforcement setting. Enforcing signing
        or channel binding is a deliberate, staged change and this script will not do it.
        Output contains client IP addresses and account names. Treat as sensitive.
    #>
    
    #requires -Version 5.1
    #requires -RunAsAdministrator
    
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [ValidateRange(1, 90)]
        [int]$EventDays = 14,
    
        [ValidateScript({ Test-Path -Path (Split-Path -Path $_ -Parent) -PathType Container })]
        [string]$ReportPath,
    
        [switch]$EnableClientLogging,
    
        [switch]$DisableClientLogging
    )
    
    $ErrorActionPreference = 'Stop'
    
    if ($EnableClientLogging -and $DisableClientLogging) {
        throw 'Choose either -EnableClientLogging or -DisableClientLogging, not both.'
    }
    
    $parametersKey = 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Parameters'
    $diagnosticsKey = 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics'
    $diagnosticValue = '16 LDAP Interface Events'
    
    function Get-RegistryValue {
        param(
            [Parameter(Mandatory)][string]$Path,
            [Parameter(Mandatory)][string]$Name
        )
        try { (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name }
        catch { $null }
    }
    
    # --- Current enforcement ----------------------------------------------------------------
    $signing = Get-RegistryValue -Path $parametersKey -Name 'LDAPServerIntegrity'
    $channelBinding = Get-RegistryValue -Path $parametersKey -Name 'LdapEnforceChannelBinding'
    # Windows Server 2025 introduced a separate enforcement value that takes precedence.
    $signingEnforcement = Get-RegistryValue -Path $parametersKey -Name 'LDAPServerEnforceIntegrity'
    $diagnosticLevel = Get-RegistryValue -Path $diagnosticsKey -Name $diagnosticValue
    
    $signingState = switch ($signing) {
        2 { 'Required' }
        0 { 'Not required' }
        $null { 'Not set (default for this Windows version applies)' }
        default { "Unexpected value: $signing" }
    }
    
    $channelBindingState = switch ($channelBinding) {
        0 { 'Never' }
        1 { 'When supported' }
        2 { 'Always required' }
        $null { 'Not set (default for this Windows version applies)' }
        default { "Unexpected value: $channelBinding" }
    }
    
    Write-Verbose "LDAP signing: $signingState. Channel binding: $channelBindingState. Diagnostic level: $(if ($null -ne $diagnosticLevel) { $diagnosticLevel } else { 0 })."
    
    # --- Evidence ---------------------------------------------------------------------------
    $since = (Get-Date).AddDays(-$EventDays)
    $summary = @{}
    $clients = [System.Collections.Generic.List[object]]::new()
    
    $eventMeanings = @{
        2886 = 'Signing is not required (reminder logged at directory service start)'
        2887 = 'Daily summary of unsigned binds ACCEPTED'
        2888 = 'Daily summary of unsigned binds REJECTED'
        2889 = 'Per-client unsigned bind'
        3039 = 'Client did not provide a channel binding token'
        3040 = 'Client channel binding token did not validate'
        3041 = 'Client bound successfully using channel binding'
    }
    
    try {
        $events = Get-WinEvent -FilterHashtable @{
            LogName   = 'Directory Service'
            Id        = $eventMeanings.Keys
            StartTime = $since
        } -ErrorAction Stop
    
        foreach ($record in $events) {
            $summary[$record.Id] = 1 + ($(if ($summary.ContainsKey($record.Id)) { $summary[$record.Id] } else { 0 }))
    
            if ($record.Id -ne 2889) { continue }
    
            # 2889 carries the client address and the identity that bound without signing.
            $address = if ($record.Message -match 'Client IP address:\s*(\S+)') { $Matches[1] } else { 'unknown' }
            $identity = if ($record.Message -match 'Identity the client attempted to authenticate as:\s*(.+)') { $Matches[1].Trim() } else { 'unknown' }
    
            $clients.Add([pscustomobject]@{
                    TimeCreated = $record.TimeCreated
                    ClientIp    = ($address -split ':')[0]
                    Identity    = $identity
                })
        }
    }
    catch {
        Write-Verbose "No qualifying Directory Service events in the last $EventDays days: $($_.Exception.Message)"
    }
    
    $distinctClients = $clients |
        Group-Object ClientIp |
        ForEach-Object {
            [pscustomobject]@{
                ClientIp      = $_.Name
                BindCount     = $_.Count
                Identities    = (($_.Group.Identity | Select-Object -Unique) -join '; ')
                FirstSeenUtc  = ($_.Group.TimeCreated | Sort-Object | Select-Object -First 1).ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
                LastSeenUtc   = ($_.Group.TimeCreated | Sort-Object | Select-Object -Last 1).ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
            }
        } | Sort-Object BindCount -Descending
    
    if ($ReportPath -and $distinctClients) {
        $distinctClients | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
        Write-Verbose "Wrote $ReportPath"
    }
    
    # --- Readiness verdict ------------------------------------------------------------------
    $unsignedAccepted = $summary[2887]
    $perClientLogging = ($diagnosticLevel -ge 2)
    
    $verdict =
    if ($signingState -eq 'Required' -and $channelBindingState -eq 'Always required') { 'Enforced' }
    elseif ($distinctClients.Count -gt 0) { "NOT READY: $($distinctClients.Count) distinct client(s) are still binding without signing" }
    elseif (-not $perClientLogging) { 'UNKNOWN: per-client logging is off, so an absence of 2889 events proves nothing' }
    elseif ($unsignedAccepted) { 'NOT READY: unsigned binds are still being accepted (see event 2887)' }
    else { 'No unsigned binds observed in this window on this domain controller' }
    
    if ($verdict -like 'NOT READY*') { Write-Warning $verdict }
    if (-not $perClientLogging -and -not $EnableClientLogging) {
        Write-Warning 'Per-client logging is off. Run with -EnableClientLogging to identify individual clients, then turn it off again.'
    }
    
    # --- Optional diagnostic level changes ---------------------------------------------------
    if ($EnableClientLogging) {
        if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Set '$diagnosticValue' diagnostic level to 2 (increases Directory Service log volume)")) {
            Set-ItemProperty -Path $diagnosticsKey -Name $diagnosticValue -Value 2 -Type DWord -ErrorAction Stop
            Write-Verbose 'Per-client LDAP logging enabled. Collect for a representative period, then disable it.'
        }
    }
    
    if ($DisableClientLogging) {
        if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Set '$diagnosticValue' diagnostic level to 0")) {
            Set-ItemProperty -Path $diagnosticsKey -Name $diagnosticValue -Value 0 -Type DWord -ErrorAction Stop
            Write-Verbose 'Per-client LDAP logging disabled.'
        }
    }
    
    [pscustomobject]@{
        DomainController     = $env:COMPUTERNAME
        CollectedUtc         = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
        LdapSigning          = $signingState
        ChannelBinding       = $channelBindingState
        SigningEnforcement2025 = $signingEnforcement
        PerClientLogging     = $perClientLogging
        EventSummary         = $summary.GetEnumerator() | Sort-Object Name | ForEach-Object {
            [pscustomobject]@{ Id = $_.Name; Count = $_.Value; Meaning = $eventMeanings[$_.Name] }
        }
        UnsignedClients      = $distinctClients
        Verdict              = $verdict
    }

    Running a readiness project with this

    1. Enable per-client logging on every domain controller, not a sample. Clients bind to whichever DC they discover; a sample produces a list you will later discover was partial.
    2. Collect across a representative period. Include a month-end, a payroll run and an overnight batch window. The appliance that binds once a month is the one that will generate the incident.
    3. Resolve the addresses to owners. An IP address is not an action. The output of this exercise should be a list of systems with named owners and a vendor position on LDAP signing.
    4. Fix the clients, then enforce. Most fixes are configuration — use LDAPS, or enable signing on the client — rather than replacement.
    5. Enforce in a staged way, one domain controller at a time, watching event 2888 for rejections. A mixed state is fine during the roll-out: a client that can sign will sign against any DC.
    6. Turn the diagnostic level back off.

    Do not skip step 6, and do not leave step 1 running indefinitely as a substitute for a decision.

    The two failure modes to expect

    A client that supports signing but fails channel binding, because an intermediary — a load balancer terminating TLS, or a proxy — breaks the token computation. This is why appliances that work fine over plain LDAP fail when channel binding is enforced over LDAPS.

    And the mixed-DC case: if clients are configured to require signing while one domain controller still does not, those clients fail against that one DC and work everywhere else. The symptom is intermittent authentication failure that follows no pattern until someone correlates it with which DC answered.

    Verification and limits

    The registry paths and value names (LDAPServerIntegrity, LdapEnforceChannelBinding, LDAPServerEnforceIntegrity, and the 16 LDAP Interface Events diagnostic under the NTDS Diagnostics key), the numeric value meanings, the Group Policy setting names, events 2886, 2887, 2888, 2889, 3039, 3040 and 3041 and the Directory Service channel, the requirement to raise the diagnostic level to get per-client detail, and the Windows Server 2025 default behaviour including the preserved settings on in-place upgrade were checked against current Microsoft documentation on 20 September 2026.

    The script was written for this article and statically analysed; it was not run against a production domain controller.

    Two honest gaps. The exact option labels in the channel binding Group Policy setting are not quoted here because I could not verify their wording, so the script reports the registry values and their documented meanings instead. And the field labels the script parses out of event 2889 (“Client IP address”, “Identity the client attempted to authenticate as”) are taken from the event’s own message text: if your domain controllers are localised, adjust the two regular expressions or read the event’s properties positionally.

    References