To prevent RDP session hijacking on port 3389 you have to close two doors: the listener that anyone on the internet can reach, and the local rights that let a logged-in account attach itself to somebody else's session without a password. Both are configuration problems on your own server, and both are fixable in a single maintenance window.
This guide assumes you already connect as 203.0.113.10:3389, so the network side is working. Now you tighten what happens after the connection lands.
Before you start
- An Admin RDP or Windows VPS with local administrator rights. Check the IP, username and password in your VPS provider's control panel.
- A working connection on port 3389.
- The browser console open in a second tab. It is your way back in if a firewall or logon change goes wrong. Open it from your VPS provider's control panel.
- Your current public IP, which you will allow through the firewall:
Invoke-RestMethod https://api.ipify.org
Warning: Several steps change who may log on and which traffic reaches the server. Apply them from a session you keep open, and test a second connection from another device before you sign out. If you lock yourself out, use your VPS provider's console to undo the change.
Step 1 — Confirm the listener, NLA and encryption
First prove that Windows is listening on 3389 and that the connection requires Network Level Authentication.
Get-NetTCPConnection -LocalPort 3389 -State Listen |
Select-Object LocalAddress, LocalPort, OwningProcess
Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' |
Select-Object PortNumber, UserAuthentication, SecurityLayer, MinEncryptionLevelPortNumber should read 3389, UserAuthentication should be 1 (NLA on), SecurityLayer 2 (TLS) and MinEncryptionLevel 3 (high). Set them if they differ:
$rdp = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'
Set-ItemProperty -Path $rdp -Name UserAuthentication -Value 1
Set-ItemProperty -Path $rdp -Name SecurityLayer -Value 2
Set-ItemProperty -Path $rdp -Name MinEncryptionLevel -Value 3NLA means credentials are verified before a session is created, which removes the pre-authentication window attackers use to probe the listener. The full procedure, including the Group Policy route, is in Enable Network Level Authentication on RDP. A service restart applies the settings:
Restart-Service -Name TermService -ForceWarning: Restarting the service disconnects every RDP session on the server, including yours. Do it from your VPS provider's console or accept that you will reconnect.
Step 2 — Limit who can open a Remote Desktop session
Session takeover almost always starts with an ordinary account that should never have been allowed to log on remotely. List the members of both groups:
Get-LocalGroupMember -Group 'Remote Desktop Users' | Select-Object Name, ObjectClass
Get-LocalGroupMember -Group 'Administrators' | Select-Object Name, ObjectClass
Remove-LocalGroupMember -Group 'Remote Desktop Users' -Member 'CONTOSO\contractor'Only accounts that genuinely need an interactive desktop belong in either group. Then deny RDP logon for the accounts that must never use it — service accounts, guest-type accounts, shared logins:
- Run
secpol.msc→ Local Policies → User Rights Assignment → Deny log on through Remote Desktop Services. - Add the account or group, apply, and confirm with
gpupdate /force.
If your accounts come from a domain, do the same in the domain policy rather than locally. The wider set of user-rights controls is covered in Using Local Security Policy to limit user rights.
Step 3 — Block RDP session hijacking through tscon and shadowing
The classic takeover on a Windows host uses tscon to attach a disconnected session to the attacker's own session. It needs SeTcbPrivilege, which ordinary users do not hold — so the defence is to keep the local Administrators group small, avoid running daily work as an administrator, and make any use of tscon visible in the logs.
Turn off session shadowing and single-session sprawl through policy:
$ts = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services'
New-Item -Path $ts -Force | Out-Null
Set-ItemProperty -Path $ts -Name Shadow -Value 0 # no remote control of sessions
Set-ItemProperty -Path $ts -Name fSingleSessionPerUser -Value 1 # one session per account
Set-ItemProperty -Path $ts -Name fPromptForPassword -Value 1
Set-ItemProperty -Path $ts -Name MaxIdleTime -Value 900000 # 15 minutes
Set-ItemProperty -Path $ts -Name MaxDisconnectionTime -Value 900000 # 15 minutes
gpupdate /forceShort idle and disconnect limits mean an abandoned session — the easiest thing to hijack — disappears instead of sitting there for days. Confirm the intended behaviour in Microsoft's documentation before you rely on a specific registry value, since names and defaults differ between Windows Server releases. If you genuinely need shadowing, read View or control another user's RDP session and require user consent.
Also discourage leaving elevated tools open in disconnected sessions: sign out with logoff rather than closing the RDP window.
Step 4 — Scope port 3389 to your own IP addresses
Find every inbound rule that opens 3389:
Get-NetFirewallPortFilter |
Where-Object { $_.LocalPort -eq '3389' } |
ForEach-Object { Get-NetFirewallRule -AssociatedNetFirewallPortFilter $_ } |
Select-Object DisplayName, Enabled, Profile, ActionIf a rule allows 3389 from Any, replace it with a scoped one:
New-NetFirewallRule -DisplayName 'RDP 3389 - office only' -Direction Inbound `
-Protocol TCP -LocalPort 3389 -RemoteAddress 203.0.113.10 -Action Allow `
-Profile Any -Group 'Remote Desktop custom'
Disable-NetFirewallRule -DisplayName 'Remote Desktop - User Mode (TCP-In)'Warning: Test a new connection from the allowed address before you close your current session. If the rule is wrong, only your VPS provider's console gets you back in.
The step-by-step version, including how to keep several addresses on the list, is in Restrict RDP port 3389 to your own IP addresses.
Step 5 — Audit logons and takeover attempts
Without logs you will never know a session was reused. Turn on the relevant auditing:
auditpol /set /subcategory:"Logon" /success:enable /failure:enable
auditpol /set /subcategory:"Other Logon/Logoff Events" /success:enable /failure:enable
auditpol /set /subcategory:"Process Creation" /success:enableThen check who connected recently and whether tscon has ever run:
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4624} -MaxEvents 200 |
Where-Object { $_.Message -match 'Logon Type:\s+10' } |
Select-Object TimeCreated, @{n='User';e={$_.Properties[5].Value}}
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4688} -MaxEvents 200 |
Where-Object { $_.Message -like '*tscon*' } |
Select-Object TimeCreated, MessageEvent 4778 (session reconnected) and 4779 (session disconnected) are the ones that appear when someone reattaches a session. Forward the Security log off the machine, or review it on a schedule — a log that only exists on the compromised host is of little use. For wider detection, see Monitoring for unauthorized port scans.
Check it works
Get-NetTCPConnection -LocalPort 3389 -State Listenstill returns a listener, and only one.- A fresh RDP connection to
203.0.113.10:3389succeeds from your allowed address and fails from a network you have not permitted. Get-LocalGroupMember -Group 'Remote Desktop Users'lists only accounts you recognise.- The registry values from Step 3 read back as set, and
gpupdate /forcereports no errors. - You can sign in to the server twice with the same account and only one session appears, because
fSingleSessionPerUseris on.
Troubleshooting
- Connection is refused after the firewall change — the scoped rule does not match your current IP, which changes on mobile or hotel networks. Add the new address, or reach the server through your VPS provider's console and check
Get-NetFirewallRule -Group 'RDP'. - "The connection was denied because the user account is not authorized for remote login" — the account was removed from Remote Desktop Users or is caught by a Deny log on through Remote Desktop Services entry. Re-add it, or remove the deny entry in
secpol.msc. - Everyone is disconnected at random — the idle and disconnect limits in Step 3 are firing. Raise
MaxIdleTimeandMaxDisconnectionTime, or set them to0on a lab machine while you test. - Session count jumps and old sessions linger —
fSingleSessionPerUseris not applied. Rungpupdate /force, confirm the policy value underHKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services, then sign out stale sessions as described in Sign out a stuck or disconnected RDP session. - You suspect an active takeover right now — check event 4778 for reconnects you did not make, review group membership, and rotate the passwords of every account in Administrators. Reinstalling the OS from your VPS provider's control panel is the safe option if you cannot explain what you find.
Next steps
- Apply the companion Group Policy baseline in RDP Hardening: Preventing Privilege Escalation via Group Policy.
- Work through Windows Server: securing RDP session management for timeouts, redirection limits and device policy.
- Read Windows Firewall: preventing unauthorized incoming connections to review other open ports on the same server.
