Secure RDP: protect Windows Server against attacks
A freshly installed Windows server collects thousands of failed RDP logons within hours. This guide shows which measures actually work, what each one costs you and how not to lock yourself out.
A Windows server that goes online with port 3389 open does not get attacked at some point, it gets attacked within minutes. The scanners run around the clock, they know every IPv4 range, and they simply grind through usernames and passwords. Almost every ransomware incident on a small server starts at exactly this spot: an account called Administrator, a password somebody considered good enough, and no lockout after the thousandth failed attempt.
This guide works through the measures in the order of their actual effect, not in the order most articles use. Every command runs in an elevated PowerShell session on Windows Server 2016 through 2025.
Why RDP is the most attacked way into a server
RDP is not a bad protocol. The problem is that it puts a full login form on the open internet, and that Windows lets anyone fill in that form an unlimited number of times by default. An attacker does not need a vulnerability, only patience and a password list.
You can check how badly your server is affected in a single line. Count the failed logons of the last 24 hours:
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4625; StartTime=(Get-Date).AddDays(-1)} -ErrorAction SilentlyContinue | Measure-Object | Select-Object -ExpandProperty Count
An internal server with no internet exposure typically sits in the low double digits per day, mostly forgotten passwords and old service accounts. A server with 3389 open reaches four or five digits very quickly. That number is your benchmark: after the measures below it should drop by orders of magnitude. If Get-WinEvent comes back with "No events were found that match the specified selection criteria.", your server is not logging failed logons at all. We fix that further down.
Before the first change: secure your way back
Every one of the following measures can lock you out. On a server you only reach over RDP, that is the end of the session and the start of a long evening. Three precautions cost five minutes and prevent exactly that.
First: Check that you have a console that works independently of RDP. On KVM root servers from KernelHost you will find a VNC console in the customer panel that looks straight at the screen of the virtual machine. It still works when firewall, network and the RDP service are broken all at once. Open it once as a test before you change anything, not afterwards.
Second: Create a second administrator account, so that one locked login does not mean a lost server. Group names depend on the display language, so we work with the fixed SIDs (S-1-5-32-544 is the local Administrators group, S-1-5-32-555 the Remote Desktop Users):
New-LocalUser -Name 'kh-adm' -Password (Read-Host -AsSecureString -Prompt 'Kennwort') -FullName 'Wartungskonto' -PasswordNeverExpires
Add-LocalGroupMember -SID 'S-1-5-32-544' -Member 'kh-adm'
Add-LocalGroupMember -SID 'S-1-5-32-555' -Member 'kh-adm'
Strictly speaking the third line is redundant, because members of the local Administrators group get in over RDP anyway. It does no harm and it makes the intent visible in case you later remove the account from the Administrators group.
Third: Keep the RDP session you are working in open for the whole changeover and test every change with a second, new connection. An existing session is not dropped immediately by new firewall rules, while a new connection fails right away. That way you notice the mistake while you can still take it back.
Enforce Network Level Authentication
Without Network Level Authentication (NLA), the server first builds a session, draws a sign-in screen and asks for the credentials afterwards. Every anonymous connection attempt therefore costs RAM and CPU, and all the code behind the sign-in screen is reachable before any authentication takes place. That is exactly where the serious RDP vulnerabilities of the past were sitting.
With NLA the server verifies the credentials over CredSSP before a session exists at all. A bot without valid credentials gets nothing but a rejected TLS connection.
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name 'UserAuthentication' -Value 1
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name 'SecurityLayer' -Value 2
SecurityLayer set to 2 enforces TLS for the negotiation. The value 1 (negotiate) lets a client fall back to the old RDP security layer if it has to, 0 is that old layer without TLS. Verify:
Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' | Select-Object UserAuthentication, SecurityLayer
No reboot is needed, the values apply to every newly established connection. Existing sessions keep running unchanged, which is handy if you are in the middle of locking out a client.
When nobody gets in afterwards
Two symptoms show up regularly once NLA is switched on.
"The remote computer requires Network Level Authentication, which your computer does not support. For assistance, contact your system administrator or technical support." The client is too old or does not speak CredSSP. Current Windows clients have handled it for years. On Linux, FreeRDP needs the /sec:nla switch and Remmina needs its security protocol set to NLA, while very old macOS clients fail outright. The answer is always the newer client, never switching NLA off.
Sign-in fails with the correct password. This is the trap most guides leave out: if an account has "User must change password at next logon" set, or the password has expired, that account can no longer sign in at all while NLA is active. CredSSP cannot carry out a password change, and that is by design. Depending on the version, the server reports an authentication error or simply wrong credentials. The way out leads through the console in the customer panel, or you exempt maintenance accounts from password expiry:
Set-LocalUser -Name 'kh-adm' -PasswordNeverExpires $true
Account lockout: the single most effective switch
A strong password protects against guessing, an account lockout protects against unlimited guessing. Without it an attacker may make millions of attempts per week, with it the number is ten per quarter of an hour. Set threshold, lockout duration and observation window in one call, otherwise Windows rejects the duration while the threshold is still 0:
net accounts /lockoutthreshold:10 /lockoutduration:15 /lockoutwindow:15
The lockout duration must always be greater than or equal to the observation window. Check the result:
net accounts
Newer Windows versions already ship a default in this range, older installations and many provider images do not. Checking costs nothing.
Including the built-in Administrator account
Historically, the one account every attacker tries first was exempt from the lockout. The policy "Allow Administrator account lockout" exists for that. It does require a newer build, namely Windows 11 22H2 or Windows Server 2025. On a Windows Server 2022 (build 20348) the security policy export does not contain the line AllowAdministratorLockout at all, and the [System Access] section holds only values such as LockoutBadCount and MinimumPasswordLength. So check first what your system actually exports:
secedit /export /cfg C:\secpol.inf
Select-String -Path C:\secpol.inf -Pattern 'AllowAdministratorLockout'
If nothing comes back, your build does not know the policy and this section is finished for you. And this is exactly where most guides create a trap: the usual one-liner with -replace substitutes a string that is not in the file at all, reports no error, and the following secedit /configure confirms success. Afterwards you believe the Administrator lockout is active although nothing has changed.
On a build that does know the policy, you change the line if it is there and insert it if it is not. This version covers both cases:
$c = Get-Content C:\secpol.inf
if ($c -notmatch 'AllowAdministratorLockout') { $c = $c -replace '(LockoutBadCount = \d+)', "`$1`r`nAllowAdministratorLockout = 1" } else { $c = $c -replace 'AllowAdministratorLockout = 0','AllowAdministratorLockout = 1' }
$c | Set-Content C:\secpol.inf -Encoding Unicode
The -Encoding Unicode is mandatory and not a detail: the INF file has to be UTF-16 LE with a byte order mark. Otherwise Set-Content writes ANSI in Windows PowerShell 5.1 and secedit refuses to accept the file. Before writing it back, secedit /validate C:\secpol.inf checks the file, then:
secedit /configure /db C:\Windows\security\local.sdb /cfg C:\secpol.inf /areas SECURITYPOLICY
And afterwards read it back without exception, because a successful secedit run proves nothing at this point. Export the policy again into a second file and check that the value really is in there with 1.
On a domain controller these local settings have no effect. There the lockout policy belongs in the Default Domain Policy under Computer Configuration, Windows Settings, Security Settings, Account Policies.
The downside and how you get back in
An account lockout is also a weapon against you: anyone who knows your username can keep it permanently locked by sending ten wrong passwords every 15 minutes. That is precisely why the lockout is only the second line of defense, the first one is the IP restriction in the next section.
If a local account is locked, the client reports something along the lines of "The referenced account is currently locked out and may not be logged on to." The simplest way back is to wait, because Windows unlocks the account by itself once the lockout duration has expired. If you do not want to wait, unlock it from the console:
$u = [ADSI]"WinNT://./kh-adm,user"; $u.IsAccountLocked = $false; $u.SetInfo()
In Active Directory it is shorter with Unlock-ADAccount -Identity kh-adm.
Passwords that make a lockout worthwhile in the first place
Ten attempts per quarter of an hour are only an obstacle if the password is not third on every list. Set minimum length and complexity like this:
net accounts /minpwlen:14
The complexity rule again lives in the security policy, section [System Access], key PasswordComplexity = 1. The procedure is the same as above with secedit.
Two points from practice: first, a forced password change every 30 days demonstrably achieves little, and combined with NLA it produces exactly the sign-in problem from the previous section. Long passwords, set once and kept in a password manager, are better. Second, the policy only applies to newly set passwords. An existing six-character password stays valid until you change it.
Limit access to known IP addresses
This is the measure that really ends the attack traffic, and ends it completely. All rules in the Remote Desktop group can be restricted to an address list. Use the language-neutral group identifier so that the script also works on localized installations:
Get-NetFirewallRule -Group '@FirewallAPI.dll,-28752' | Select-Object Name, DisplayName, Enabled, Profile
Set-NetFirewallRule -Group '@FirewallAPI.dll,-28752' -RemoteAddress @('203.0.113.10','198.51.100.0/24')
Expect more hits than you expect rules. -Group touches every rule in the group, which on a test system was six of them, including the shadow rules and additional GUID-named copies in the Public profile. That is intended and correct, because otherwise one of the copies would stay open. Listing them first with Get-NetFirewallRule shows you in advance what will be affected.
Check which addresses were actually stored:
Get-NetFirewallRule -Group '@FirewallAPI.dll,-28752' | Get-NetFirewallAddressFilter
If you have changed the port, the built-in rules no longer apply, because they are bound to 3389. In that case you need a rule of your own:
New-NetFirewallRule -DisplayName 'RDP eingeschraenkt' -Direction Inbound -Protocol TCP -LocalPort 34567 -RemoteAddress '203.0.113.10' -Action Allow -Profile Any
The lifeline against your own firewall rule
Anyone who mistypes their IP address or enters a dynamic one will reliably lock themselves out. So create a task beforehand that undoes the restriction on its own after ten minutes:
Set-Content -Path C:\rdp-rettung.ps1 -Value "Set-NetFirewallRule -Group '@FirewallAPI.dll,-28752' -RemoteAddress Any"
Register-ScheduledTask -TaskName 'RDP-Rettung' -Action (New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-ExecutionPolicy Bypass -File C:\rdp-rettung.ps1') -Trigger (New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(10)) -User 'SYSTEM' -RunLevel Highest
If the new connection works, remove the task again:
Unregister-ScheduledTask -TaskName 'RDP-Rettung' -Confirm:$false
If you do not have a static IP address, the clean solution is not to put RDP on the internet at all, but to reach it over a VPN. How to set that up is described in our guide to the WireGuard VPN server. The server then only listens on the VPN address, and port 3389 disappears from the internet entirely.
Changing the port and what it actually achieves
A different port is not a security measure, it is a noise reduction measure. The vast majority of bots scan 3389 and nothing else, so they no longer find you, which as a rule cuts your 4625 count drastically and makes the event logs readable again. Anyone searching deliberately still finds the service: search engines for exposed services recognize RDP by its protocol fingerprint, independently of the port, and a full port scan across 65535 ports takes seconds.
The port change is therefore useful as an addition, but never as a replacement for NLA, account lockout and address restriction. We have described the practical part separately, including the piece almost every guide gets wrong, namely doing it without a reboot: change the RDP port without a reboot. In any case, remember to create a firewall rule for the new port before you switch the service over.
Evaluating the logon logs
First of all, logging has to be switched on at all. The subcategory names that auditpol expects are localized, so a command written with the English name fails on a localized system with "Error 0x00000057 occurred: The parameter is incorrect." The GUID, in contrast, works on every language version:
auditpol /set '/subcategory:{0CCE9215-69AE-11D9-BED3-505054503030}' /success:enable /failure:enable
auditpol /get '/subcategory:{0CCE9215-69AE-11D9-BED3-505054503030}'
The single quotes around the whole parameter are not a matter of taste, they are mandatory. Otherwise PowerShell reads the curly braces as a script block and strips them, one argument turns into three, and auditpol aborts with Error 0x00000057 occurred: The parameter is incorrect. and exit code 87, followed by the help text. In cmd.exe the notation works without quotes, in PowerShell it does not. With quotes both run through with exit code 0, and the verification query then answers Logon Success and Failure.
Under attack the security log rolls over within a few hours and overwrites exactly the entries you need. Give it more room:
wevtutil sl Security /ms:1073741824
Then the most frequent source addresses of the last week, sorted by count. The variant that goes through the XML structure is the robust one, because it does not depend on the field order:
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4625; StartTime=(Get-Date).AddDays(-7)} -ErrorAction SilentlyContinue | ForEach-Object { ([xml]$_.ToXml()).Event.EventData.Data | Where-Object { $_.Name -eq 'IpAddress' } | Select-Object -ExpandProperty '#text' } | Group-Object | Sort-Object Count -Descending | Select-Object -First 15 Count, Name
The -ErrorAction SilentlyContinue belongs on every one of these calls. Get-WinEvent aborts with a red error as soon as there is not a single matching event in the period: "No events were found that match the specified selection criteria." On a freshly hardened server that is precisely the normal case, so the command fails on exactly the success this guide is aiming for.
The decisive question, however, is not who tried, but whether anyone made it. Successful Remote Desktop logons carry event ID 4624 with logon type 10 (RemoteInteractive):
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4624; StartTime=(Get-Date).AddDays(-7)} -ErrorAction SilentlyContinue | Where-Object { $_.Properties[8].Value -eq 10 } | Select-Object TimeCreated, @{n='User';e={$_.Properties[5].Value}}, @{n='Source';e={$_.Properties[18].Value}} | Format-Table -AutoSize
If there is a username or a source address in there that you cannot account for, somebody has found a valid password. At that point no amount of policy tightening helps, the server has to be rebuilt.
It is also worth a look at the dedicated RDP log. Event 1149 names the user, the domain and the source address of every authorized connection in a single line:
Get-WinEvent -LogName 'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational' -FilterXPath '*[System[EventID=1149]]' -MaxEvents 25 | Format-List TimeCreated, Message
Rename Administrator, better still disable it
The built-in Administrator account is the one username every attacker knows for certain. Renaming it costs nothing:
Rename-LocalUser -Name 'Administrator' -NewName 'kh-svc'
But be clear about the limits: the SID of the account still ends in -500, and any authenticated access can resolve the new name through it. Renaming works against mass bots, but not against a targeted attacker who already has a foot in the door. This is how you find the account despite the new name:
Get-LocalUser | Where-Object { $_.SID.Value -like '*-500' } | Select-Object Name, Enabled
Considerably more effective is disabling the built-in account completely, once your own administrator account from the "secure your way back" section has demonstrably worked. Test the login with the new account in a second session, and only then:
Disable-LocalUser -Name 'kh-svc'
On top of that, restrict RDP access to the people who need it. By default every member of the local Administrators group may come in over RDP, including service accounts that should never do so. This shows who is currently allowed:
Get-LocalGroupMember -SID 'S-1-5-32-555'
How you can tell that it really worked
Tick off instead of hoping. These five checks tell you whether the changeover has taken effect:
- NLA:
Get-ItemPropertyonRDP-TcpreturnsUserAuthentication : 1andSecurityLayer : 2. A new connection now asks for the credentials before the connection is established, no longer on a sign-in screen inside the window. - Lockout:
net accountsshows a lockout threshold other than "Never". Cross-check with a throwaway account: after the eleventh wrong password the client has to show the lockout message, not the message about wrong credentials any more. - Firewall:
Get-NetFirewallAddressFiltershows your addresses instead ofAny. A connection test from a foreign address has to run into a timeout, not into a login prompt. Check that from the outside withTest-NetConnection -ComputerName deinserver -Port 3389, the result has to readTcpTestSucceeded : False. - Logs:
auditpol /getreports success and failure for the subcategory. - The number: Count the 4625 events again 24 hours after the changeover. It has to be considerably lower. If it stays high, one of your rules is not taking effect, usually because a second, more permissive firewall rule for port 3389 exists that a provider image or a software installation has created. This line finds it:
Get-NetFirewallPortFilter | Where-Object { $_.LocalPort -eq 3389 } | Get-NetFirewallRule | Select-Object DisplayName, Enabled, Profile, Action. The order in the pipeline is deliberate. Turn it around and push all firewall rules throughGet-NetFirewallPortFilterfirst, and the call takes a measured 12 seconds instead of one, and above all the rule name is missing from the output: you then see four hits without learning which rules they are.
If you are setting up a server from scratch anyway, work through these points right at the beginning instead of retrofitting them later. The same pattern applies to Linux systems with SSH instead of RDP, described in secure SSH and set up key login, and the order of the first steps is in our checklist for new root servers.
What RDP hardening does not cover
The measures above protect against logon attempts. They do not protect against volumetric attacks that aim to make the server unreachable through its connectivity. The only thing that helps there is filtering in the network upstream. How these attacks work is explained in what is a DDoS attack, and which precautions still make sense on the server itself is covered in protecting servers against DDoS attacks. All KernelHost servers stand in the maincubes datacenter in Frankfurt am Main behind filtering that catches attack traffic before it reaches the server.
And they are no substitute for a backup. A server somebody has successfully signed in to is no longer trustworthy, no matter how quickly you change the password afterwards. The only reliable way back is a backup taken before the incident.
Frequently asked questions
Is it enough to move the RDP port from 3389 to a different one?
I locked myself out after setting a firewall rule. What now?
Why can a user no longer sign in with the correct password since NLA is active?
Is the built-in Administrator account covered by the account lockout?
Can an attacker lock me out permanently through the account lockout?
How do I tell whether an attack succeeded?
2026 KernelHost GmbH. All rights reserved. This guide is protected by copyright. Republishing it on other websites, in whole, in part or in edited form, is not permitted without our written consent. Quoting with a source credit and a link is expressly welcome.

