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
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
- 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.
- 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.
- 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.
- Fix the clients, then enforce. Most fixes are configuration — use LDAPS, or enable signing on the client — rather than replacement.
- 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.
- 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
Reader feedback
Was this article useful?
No ratings yet. Be the first to rate this article.
