Secure RDP: protect Windows Server against attacks

Published on 16 min read

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:

  1. NLA: Get-ItemProperty on RDP-Tcp returns UserAuthentication : 1 and SecurityLayer : 2. A new connection now asks for the credentials before the connection is established, no longer on a sign-in screen inside the window.
  2. Lockout: net accounts shows 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.
  3. Firewall: Get-NetFirewallAddressFilter shows your addresses instead of Any. A connection test from a foreign address has to run into a timeout, not into a login prompt. Check that from the outside with Test-NetConnection -ComputerName deinserver -Port 3389, the result has to read TcpTestSucceeded : False.
  4. Logs: auditpol /get reports success and failure for the subcategory.
  5. 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 through Get-NetFirewallPortFilter first, 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?
No. A different port keeps out the mass of bots that scan 3389 and nothing else, and it clearly lowers the number of attack attempts in the logs. Targeted scanners, however, recognize RDP by its protocol fingerprint on any port at all. Changing the port is a useful addition to NLA, account lockout and IP restriction, but no replacement for them.
I locked myself out after setting a firewall rule. What now?
Connect through the VNC console in the customer panel, which works independently of RDP and of the network, and undo the restriction with Set-NetFirewallRule -Group '@FirewallAPI.dll,-28752' -RemoteAddress Any. To avoid the situation in the first place, create a scheduled task before the change that runs exactly this command automatically after ten minutes.
Why can a user no longer sign in with the correct password since NLA is active?
Most likely the password on that account has expired, or the option "User must change password at next logon" is set. CredSSP, the protocol behind NLA, cannot carry out a password change, so the sign-in fails completely. Change the password from the console, or exempt maintenance accounts from password expiry with Set-LocalUser -PasswordNeverExpires $true.
Is the built-in Administrator account covered by the account lockout?
Only if the policy "Allow Administrator account lockout" is active, and that requires Windows 11 22H2 or Windows Server 2025. On Windows Server 2022 (build 20348) the export with secedit /export does not contain the key AllowAdministratorLockout at all: a search and replace hits nothing there, reports no error, and secedit /configure still confirms success. So use Select-String on the export to check whether your build knows the policy at all. It is not set with net accounts but with secedit, using AllowAdministratorLockout = 1 in the [System Access] section. The lockout applies only to network logons such as RDP, signing in at the local console remains possible.
Can an attacker lock me out permanently through the account lockout?
Yes, that is the downside. Anyone who knows the username can keep the account permanently locked with deliberate failed attempts. That is exactly why restricting access to known IP addresses, or access through a VPN, is the first line of defense and the account lockout only the second. A second administrator account under a different name is a sensible addition.
How do I tell whether an attack succeeded?
Search the security log for event ID 4624 with logon type 10 and compare the username and source address with your own connections. If something does not match, the logon was not yours. It is also worth checking the Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational log, event 1149. If you find a hit, further hardening no longer helps: the server has to be rebuilt from a backup taken before the incident.

RDP Windows Server Security PowerShell Remote Desktop Firewall Brute Force Hardening