
A Daily Report of Failed RDP Logons Across Your Servers
Before you run this
This guide builds a read-only PowerShell script that pulls failed logon events (Security log Event ID 4625) from a list of servers, keeps the ones that look like RDP attempts, and writes them to a dated HTML/CSV report you can review each morning. It optionally emails that report. It creates nothing and deletes nothing on the target servers — it only reads their Security logs.
Privileges: Reading the Security log remotely requires an account that is a local Administrator on each target server, or a member of that server's Event Log Readers group. The script uses PowerShell Remoting (WinRM), so that account must also have remoting access. When you schedule it, the task must run as that account. If you run it interactively first, use an elevated PowerShell session.
Test first: Read the script before you run it. Run it by hand against one test server before you point it at your whole list, and confirm the output looks right. It touches nothing on the servers, so there is nothing to undo there — but the scheduled task you create at the end is a change to whatever host runs it, and I show you how to remove it.
One honest caveat up front: Not every failed RDP attempt is logged with the same Logon Type. Interactive RDP failures are usually LogonType 10 (RemoteInteractive), but when Network Level Authentication rejects a credential the failure can be recorded as LogonType 3 (Network). This script reports type 10 by default and lets you widen it; decide which matters for you rather than trusting a single number.
What I'm assuming
- Target servers and the host running the report are Windows Server 2019/2022, domain-joined.
- PowerShell 5.1 (the version shipped in-box). The script also runs in PowerShell 7.
- WinRM is enabled across the domain (the common default via Group Policy). If it isn't,
Enable-PSRemoting -Forceon each server is the standard fix — check Microsoft Learn forEnable-PSRemotingbefore you touch a locked-down box. - You have a writable folder for reports, e.g.
C:\Reports.
The approach
The reliable, boring way is Get-WinEvent with a FilterHashtable for Event ID 4625, executed on each server via Invoke-Command, then parsed. I parse each event's XML by field name rather than by array index, because the index order of 4625 fields is easy to get wrong and the names are stable.
There is another common path — Get-WinEvent -ComputerName <server>, which uses RPC and needs the "Remote Event Log Management" firewall rule open. It works, but I'm using Invoke-Command/WinRM here because it's what most domains already permit.
The script
Save this as Get-FailedRdpReport.ps1. Replace the obvious placeholders: the server names in $Servers, and the report folder.
#requires -Version 5.1
[CmdletBinding()]
param(
# Replace with your real server names, or read them from a file / AD.
[string[]]$Servers = @('SERVER01','SERVER02'),
# Look back this many hours. 24 = "since yesterday's run".
[int]$Hours = 24,
# Which Logon Types count as "RDP". 10 = RemoteInteractive.
# Add 3 if you also want NLA credential rejections.
[int[]]$LogonTypes = @(10),
[string]$ReportFolder = 'C:\Reports' # Must exist and be writable.
)
$start = (Get-Date).AddHours(-$Hours)
$stamp = Get-Date -Format 'yyyy-MM-dd'
# Runs ON each server; returns parsed 4625 events for the window.
$scriptBlock = {
param($start)
# Get-WinEvent throws a terminating "No events" error when a log has
# no matches, so we swallow that specific case.
$events = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4625
StartTime = $start
} -ErrorAction SilentlyContinue
foreach ($e in $events) {
$xml = [xml]$e.ToXml()
$data = @{}
foreach ($d in $xml.Event.EventData.Data) { $data[$d.Name] = $d.'#text' }
[pscustomobject]@{
Time = $e.TimeCreated
Server = $env:COMPUTERNAME
TargetUser = $data['TargetUserName']
Domain = $data['TargetDomainName']
LogonType = [int]$data['LogonType']
SourceIP = $data['IpAddress']
Workstation = $data['WorkstationName']
}
}
}
# Collect from all servers. -ErrorAction Continue so one dead host
# doesn't kill the whole run.
$all = Invoke-Command -ComputerName $Servers -ScriptBlock $scriptBlock `
-ArgumentList $start -ErrorAction Continue |
Where-Object { $_.LogonType -in $LogonTypes } |
Sort-Object Time
# Drop the PSComputerName noise Invoke-Command adds, keep our fields.
$report = $all | Select-Object Time, Server, TargetUser, Domain,
LogonType, SourceIP, Workstation
$csvPath = Join-Path $ReportFolder "FailedRDP-$stamp.csv"
$htmlPath = Join-Path $ReportFolder "FailedRDP-$stamp.html"
$report | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
$report | ConvertTo-Html -Title "Failed RDP logons - $stamp" `
-PreContent "<h2>Failed RDP logons ($stamp), last $Hours hours</h2>" |
Out-File -FilePath $htmlPath -Encoding UTF8
Write-Host "Wrote $($report.Count) events to:"
Write-Host " $csvPath"
Write-Host " $htmlPath"
A few notes on choices in there:
IpAddressis often-for console or certain local failures; that's the event's own value, not a bug.- I sort by
Timeso the report reads top-to-bottom chronologically. - The counts on screen let you sanity-check against what you expect.
Emailing the report (optional)
If you want it delivered rather than parked on a share, append a send step. Be aware: Send-MailMessage still works but Microsoft officially marks it obsolete and recommends against it for new work — it's fine for internal use, just know that. For anything you'll maintain long-term, look at a maintained module such as MailKit (check its own documentation for syntax).
Adjust the SMTP host, addresses, and port to yours:
# Replace all placeholders with your real mail settings.
Send-MailMessage `
-SmtpServer 'smtp.example.com' `
-Port 25 `
-From 'rdp-report@example.com' `
-To 'secops@example.com' `
-Subject "Failed RDP logons - $stamp" `
-Body (Get-Content $htmlPath -Raw) `
-BodyAsHtml
Scheduling it daily
Run it once by hand first (elevated) to confirm it produces a report. Then register a daily task. Do this on the host that will run the report, in an elevated session, and set it to run as your reader account:
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Get-FailedRdpReport.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 7:00am
Register-ScheduledTask -TaskName 'Daily Failed RDP Report' `
-Action $action -Trigger $trigger `
-User 'EXAMPLE\svc-rdpreport' -RunLevel Highest
# You'll be prompted for that account's password.
New-ScheduledTaskAction, New-ScheduledTaskTrigger, and Register-ScheduledTask are all part of the in-box ScheduledTasks module. See Microsoft Learn for their full parameter sets if you need a different schedule.
Verify it worked
- Run it manually against one server and confirm the on-screen count and the files:
.\Get-FailedRdpReport.ps1 -Servers 'SERVER01' -Hours 24 Get-ChildItem C:\Reports\FailedRDP-* - Generate a known failure to prove the pipeline: RDP to a test server and deliberately mistype the password once, then run the script with
-Hours 1. Your bad attempt should appear. - Confirm the scheduled task exists and check its last result after it fires:
Get-ScheduledTask -TaskName 'Daily Failed RDP Report' Get-ScheduledTaskInfo -TaskName 'Daily Failed RDP Report'LastTaskResultof0means it ran cleanly.
Undo
The script itself changes nothing on the target servers, so there's nothing to reverse there. To remove what you added on the report host:
Unregister-ScheduledTask -TaskName 'Daily Failed RDP Report' -Confirm:$false
Delete the report files under C:\Reports if you no longer want them. If you created a dedicated service account or added it to Event Log Readers on your servers, remove that membership too when you're done.
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
Related guides
Audit Local Administrators Across Many Windows Machines
This is a read-only audit. It pulls a list of computers from Active Directory, connects to each one over PowerShell Remoting (WinRM), reads the membership of the local Administrators group, and writes everything to a single CSV you can open in Excel. It creates nothing and changes nothing on the target machines, so there is no destructive step and nothing to roll back — the only thing produced is the report file on your own workstation.
Automate BitLocker Status Reporting Across a Fleet with PowerShell
This guide builds a read-only report: it collects the BitLocker encryption status of every fixed volume on a set of domain computers and writes it to a CSV. It does not enable, disable, or change BitLocker on anything. The only thing it creates on your side is the output file.
Rotate Local Admin Passwords Across Machines with PowerShell
This guide gives you a PowerShell script that connects to a list of Windows machines over WinRM and sets a brand-new random password on the built-in local Administrator account of each one , then writes the results to a CSV so you have the new credentials. Its purpose is to kill shared, static, "same password everywhere" local admin accounts.
Bulk-Disable and Archive Inactive AD Accounts with PowerShell
This procedure finds enabled user accounts that have not logged on for a set number of days , disables them, stamps a note in the account's description, and moves them into a dedicated "archive" OU so they are out of your working OUs but not deleted. Its purpose is routine account hygiene: a disabled, quarantined account can't be used for a logon, which shrinks your attack surface, but nothing is destroyed and everything is reversible.




