Hi Serge Caron,
How was the issue? It seems your previous answer has been deleted. I could not read it.
VP
This browser is no longer supported.
Upgrade to Microsoft Edge to take advantage of the latest features, security updates, and technical support.
In order to reduce complexity, I am using a test domain consisting of a single domain controller ON PREMISES and a single user, the domain administrator.
There is a single role installed: Remote Desktop Gateway. None of the other 5 RDS roles are installed. Direct Access and/or VPNs are not allowed in this test.
The AD domain is MyDomain.local and the internal FQDN is MyServer.MyDomain.local
There is a Let's Encrypt certificate installed on this server for MyServer.MyDomain.TLD.
Finally, there is a single port 443 forwarded to the server from the firewall.
I can RDP into this server from the Internet to MyServer.MyDomain.local via RDG MyServer.MyDomain.TLD.
However, all logons are downgraded to NTLMv2 even if I set
rdgiskdcproxy:i:1
kdcproxyname:s:MyServer.Mydomain.TLD
In this test case, I am using a non domain joined Windows 10 Pro client (even if I know it is deprecated): we need to demonstrate RDG working with BYOD, including non Windows devices.
Is there a way to do this in Windows Server 2025 ?
Hi Serge Caron,
How was the issue? It seems your previous answer has been deleted. I could not read it.
VP
Hello VPHAN,
Your hunch about multiple certificates is correct ... but this is not the solution.
On my production Server 2025, the RAS role was installed for a short period of time and removed. There was a binding for [::]:443 pointing to the certificate in use then.
I modified the script to detect this condition: just insert the code below after the "If ($Nul -ne $MyCert) {" statement in the server side configuration script.
It turns out the AppID of the [::]:443 association is the same on both the Server 2022 and Server 2025 production servers: {ba195980-cd49-458b-9e23-c84ee0adcd75} and the AppID of the 0.0.0.0:443 association is {4dc3e181-e14b-4a21-b022-59fc669b0914} on all three servers. The later is the IIS AppId and it is the only association shown on my test rig on which RAS was never installed.
I did update the faulty association on the production Server 2025.
And on the test rig, there is no sslcert IPv6 entry, even if the server is configured for IPv6 using a local link address, just as the production servers.
Is there a possible conflict between two apps here ?
Regards
Add-on to the server side configuration script:
### Revise the ssl bindings used by the Remote Desktop Gateway
netsh http show sslcert | Select-String -Pattern ":443" -Context 0,2 | `
ForEach-Object {
# Ugly screen washing leading to ugly Write-Only regex :-(
$URL = $($($_.Line.ToString()) -Split'.*: (.*)')[1]
$CertHash = $($($_.Context.PostContext[0].ToString()) -Split'.*: (.*)')[1]
$AppID = $($($_.Context.PostContext[1].ToString()) -Split'.*: (.*)')[1]
If ($CertHash -ne $Thumbprint) {
$ThisCert = (Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $CertHash } )
Write-Warning "Multiple certificates configured on the HTTP service."
Write-Warning "Application $AppID may be conflicting with the Remote Desktop Gateway"
Write-Warning "using $URL and certificate $($ThisCert.DNSNameList.Unicode) expiring $($ThisCert.NotAfter.ToString("yyyy.MM.dd"))"
Write-Warning ""
Write-Warning "Consider updating this entry using:"
Write-Warning "`tnetsh http update sslcert ipport=$URL certhash=$Thumbprint appid=$AppID"
Write-Warning "Enclose the appid in double quotes if using PowerSehll."
Write-Warning ""
Write-Warning "This may be an orphaned certificate: $($ThisCert.FriendlyName)"
Write-Warning ""
}
}
Hi,
You are very close to a fully robust solution. The key to the final piece of the puzzle lies in the structural difference between your test environment (DC + RDG on same box) and your production environment (RDG separate from DC, or different forest levels), and how the KDC Proxy service (KPSSVC) routes tickets when it is not running directly on a KDC.
The reason your production setups are downgrading to NTLMv2, and why the Server 2022 machine is presenting that odd realm\UPN format, is that the KDC Proxy service on a standalone (non-DC) RD Gateway, or a DC in a different environment, requires explicit instruction on where to send the Kerberos traffic.
The Missing Piece is KDC Proxy Server Registry Settings!
In your test environment (where RDG is also the DC), the KDC Proxy service (KpsSvc) implicitly knows to look at "LocalSystem" for the KDC. However, on your production Server 2022 (Member Server), the KpsSvc doesn't run a KDC locally. It acts purely as a middleman. By default, without specific registry configuration, it may fail to locate the backend Domain Controllers correctly for the incoming realm request, especially if DNS suffixes or NetBIOS names don't perfectly align with the default discovery logic. When the proxy fails to contact a backend KDC to forward the KRB_AS_REQ, it drops the connection or fails the pre-auth, causing the client (mstsc.exe) to fall back to NTLM.
The strange realm\UPN username issue on the Server 2022 login screen typically happens when the Gateway passes the credentials through NTLM but formats them incorrectly because it couldn't strip the realm during the Kerberos attempt (since that attempt failed).
=> You need to manually define the mapping between the Realm and the backend KDC servers on the RD Gateway server itself (the production machines).
Add the following registry keys to your script for the Server Side:
Navigate to: HKLM\SYSTEM\CurrentControlSet\Services\KPSSVC\Settings
Create a new Key: KdcProxy
Inside KdcProxy, create a Key named after your Realm (e.g., MYDOMAIN.LOCAL or PROD.CORP).
Inside that Realm key, create a Multi-String Value (REG_MULTI_SZ) named KdcNames.
Set the Value to the FQDNs of your actual Domain Controllers (e.g., dc01.prod.corp, dc02.prod.corp).
PowerShell Addition for your Script:
### Explicit KDC Mapping (Critical for Non-DC Gateways)
$DomainName = (Get-WmiObject Win32_ComputerSystem).Domain.ToUpper()
$KdcProxyPath = "HKLM:\SYSTEM\CurrentControlSet\Services\KPSSVC\Settings\KdcProxy\$DomainName"
if (-not (Test-Path $KdcProxyPath)) {
New-Item -Path $KdcProxyPath -Force | Out-Null
}
# Get actual DCs for the domain
$DCs = Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName
New-ItemProperty -Path $KdcProxyPath -Name "KdcNames" -Type MultiString -Value $DCs -Force
Hello VPHAN,
I am still missing a piece of this puzzle.
I used the client script above to create connections to two production "realms": the first one is using a Windows Server 2022 Standard RDG that is domain joined but is NOT a DC, the second one is a Windows Server 2025 Standard DC that is hosting the RDG role exactly like the test server I am using to validate these setups.
Below is a script that sets up the parameters on the RDG: it picks up the FQDN from the installed certificate, adds the SPN, test reserved URL, and resets KPSSVC to default values. As far as I know, it covers everything on the server side that we have seen in this discussion.
Both production servers have NTLM auditing turned on as well as Kerberos logging level 1. Supported encryption types are also set.
I can verify that my test client connects to my test server using Kerberos: the user is in the protected users group.
The same client downgrades to NTLMv2 for both production servers. In the case of the Server 2022, the connection is established but the target RDG logon screen reports an invalid logon and displays the user name as "realm\UPN" rather than the UPN that was specified. If I edit out the "realm" part, the logon is just fine ... but not what I expect.
No NTLM audit event is recorded on the Server 2022. No Security-Kerberos events are recorded in either server, which would be "normal" on successful connections but is an annoyance here.
None of these failures affects the connection to the test server.
What am I missing and would you have the information to understand these downgrades ?
Regards,
Remote Desktop Gateway configuration script:
### The Network Service User name is localized
$OutputEncoding = [console]::InputEncoding = [console]::OutputEncoding = New-Object System.Text.UTF8Encoding
$NetworkServiceSID = New-Object System.Security.Principal.SecurityIdentifier "S-1-5-20"
$NetworkServiceUserName = ($NetworkServiceSID.Translate([System.Security.Principal.NTAccount])).Value
Write-Host "The Network Service user name is:", $NetworkServiceUserName
$RDSRole = Get-WindowsFeature -Name RDS-Gateway | Select-Object Name, DisplayName, Installed, InstallState
### Make sure AD cmdlets are available
Try { Import-Module ActiveDirectory -ErrorAction Stop }
Catch { Install-WindowsFeature RSAT-AD-PowerShell }
### Who am I really ?
$RDG = $($env:COMPUTERNAME).ToUpper()
$PrivateExposure = (Get-ADComputer -Identity $RDG).DNSHostName.ToLower()
### Test if RDG is installed and parameters are set
If ($RDSRole.Installed) {
# "Get-RDCertificate -Role RDGateway" will fail if only RDG is installed :-(
Import-Module RemoteDesktopServices
$Thumbprint = (Get-Item -Path RDS:\GatewayServer\SSLCertificate\Thumbprint).CurrentValue
$MyCert = (Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $Thumbprint } )
If ($Nul -ne $MyCert) {
# Who am I
$Myself = @()
$MySelf = $MyCert.Subject.Split("=")
# Enumerate all SPNs for this host
$SPNs = setspn -L $RDG
If ($Myself.Count -eq 2) {
Write-Host "Using", $($MyCert.FriendlyName)
$PublicExposure = $Myself[1]
Write-Host "Remote Desktop Gateway is ", $PublicExposure
$Result = ($SPNs | Select-String "HTTP/$PublicExposure")
If ($Nul -ne $Result) { $Result = $Result.ToString().Trim() }
If ($Result -eq "HTTP/$PublicExposure") { Write-Host "SPN HTTP/$PublicExposure is properly registered to $RDG" }
else {
Write-Warning "Registering SPN HTTP/$PublicExposure to $RDG"
setspn -S HTTP/$PublicExposure $RDG
}
} else { Write-Warning "Cannot parse $($MyCert.Subject)" }
If ( $PrivateExposure -ne $PublicExposure ) {
$Result = ($SPNs | Select-String "HTTP/$PrivateExposure")
If ($Nul -ne $Result) { $Result = $Result.ToString().Trim() }
If ($Result -eq "HTTP/$PrivateExposure") { Write-Host "SPN HTTP/$PrivateExposure is properly registered to $RDG" }
else {
Write-Warning "Registering SPN HTTP/$PrivateExposure to $RDG"
setspn -S HTTP/$PrivateExposure $RDG
}
}
# Reset authentication DLLs
Write-Warning "Resetting Proxy Service Parameters to default values"
$KpsSvcSettingsReg = "HKLM:\SYSTEM\CurrentControlSet\Services\KPSSVC\Settings"
New-ItemProperty -Path $KpsSvcSettingsReg -Name "LibNames" -Type MultiString -Value @("kerberos.dll") -Force
New-ItemProperty -Path $KpsSvcSettingsReg -Name "HttpsClientAuth" -Type DWORD -Value 0 -Force
New-ItemProperty -Path $KpsSvcSettingsReg -Name "DisallowUnprotectedPasswordAuth" -Type DWORD -Value 0 -Force
New-ItemProperty -Path $KpsSvcSettingsReg -Name "HttpsUrlGroup" -Type MultiString -Value "+`:443" -Force
# Test/Create reserved URLs
If ($($( netsh http show urlacl url="https://+:443/kdcproxy/" ) | Select-String "kdcproxy").Count -eq 0) {
netsh http add urlacl url=https://+:443/KdcProxy user="$NetworkServiceUserName"
} else { Write-Warning "URL kdcproxy is already reserved." }
If ($($( netsh http show urlacl url="https://+:443/remoteDesktopGateway/" ) | Select-String "remoteDesktopGateway").Count -eq 0) {
netsh http add urlacl url=https://+:443/remoteDesktopGateway/ user="$NetworkServiceUserName"
} else { Write-Warning "URL remoteDesktopGateway is already reserved." }
# Restart RD Gateway
Set-Service -Name KPSSVC -StartupType Automatic
Restart-Service -Name KPSSVC
} else { Write-Warning "No certificate is installed in the Remote Desktop Service." }
} else { Write-Warning "You must install role $($RDSRole.DisplayName)" }
Hi Serge Caron,
The behavior you are observing regarding the missing tickets and logs is actually expected behavior for this specific architecture (Workgroup client to Domain KDC Proxy), and the fact that your connection succeeds with the user in the Protected Users group is the definitive proof that Kerberos is working.
Let’s clarify the "Empty Glass" mystery. A user in the Protected Users group is strictly prohibited from authenticating via NTLM, Digest Authentication, or Credential Delegation (CredSSP) using cached credentials. If the negotiation had downgraded to NTLMv2, the Domain Controller would have rejected the authentication request with a status of STATUS_ACCOUNT_RESTRICTION or STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT, and your RDP client would have shown an "Authentication Failed" error. Since you connected successfully, the authentication payload was indisputably Kerberos.
The reason you do not see the tickets in klist on the client is due to Logon Session isolation. On a domain-joined machine, the TGT is held in the user's primary logon session and is visible to all processes. On a workgroup machine using KDC Proxy, mstsc.exe (the RDP client) acts as its own authentication silo. It performs the pre-authentication, obtains the TGT and the Service Ticket for HTTP/MyServer.MyDomain.TLD, and establishes the tunnel. These tickets are often scoped specifically to the process ID or a temporary logon session (LUID) created just for that connection handle, rather than the interactive desktop session where you are running cmd.exe. Once the RDP session is established, those tickets are either purged or reside in a memory space that the default klist command (which queries the current interactive session cache) does not display. You aren't chasing a ghost; you are looking for a ticket that was used and consumed by a specific process handle.
Regarding the server logs, standard Kerberos auditing can be incredibly noisy. Unless you have enabled "Audit Kerberos Service Ticket Operations" to success in the Advanced Audit Policy Configuration, the DC will not log every ticket issuance (Event ID 4769). However, the absence of Event ID 4624 (Logon) with Authentication Package: NTLM is the positive confirmation you were looking for.
Your script is excellent. The logic you implemented to handle the SupportedEncryptionTypes via New-ItemProperty in the Catch block is the correct technical workaround. The ksetup executable has a longstanding bug where it attempts to query domain policy objects via RPC that do not exist on a standalone SAM, leading to the 0xc0000034 error. Direct registry injection into HKLM\SYSTEM\CurrentControlSet\Control\Lsa\Kerberos\Domains is the only reliable way to force AES negotiation on non-domain joined clients. This script effectively solves the "BYOD" gap for secure RDP over HTTPS.
I hope you've found something useful here. If it helps you get more insight into the issue, it's appreciated to accept the answer. Should you have more questions, feel free to leave a message. Have a nice day!
VP