Shore Up
A clerk pulling rejected entry slips stamped with a red X from an inbox, tallying them, and pinning a single dated summary sheet to a corkboard.
WindowsSecurity

Automate a Daily Failed-Logon (4625) Report with PowerShell

Ketan Aagja9 min read
No ratings yet

Before you run this

This guide builds a scheduled PowerShell job that reads Event ID 4625 (failed logon) from the Windows Security log for the last 24 hours and writes them to a dated HTML report. It is read-only — it queries the event log and creates a report file. It does not change auditing policy, delete events, or touch accounts.

Two things still need care:

  • Reading the Security log requires elevation. The account that runs the query must be a local Administrator or a member of the built-in Event Log Readers group on the machine being audited. When you test interactively, use an elevated PowerShell session ("Run as administrator"). The scheduled task below runs as SYSTEM, which can read the local Security log.
  • Test it before you schedule it. Run the script by hand once on a test or non-production server, read every line first, and confirm the report looks right before you register the daily task. Nothing here is destructive, but you don't want a broken task quietly producing empty reports for a month.

Assumptions for this guide: Windows Server 2019 or 2022, PowerShell 5.1 (the version shipped in the box), auditing for logon failures already enabled (the default on domain-joined servers), and the report saved to a local folder. If you run PowerShell 7, the cmdlets used here still work. This collects events from the local machine only — centralising across many hosts is a Windows Event Forwarding or SIEM job, not this script.

The report script

Save this as C:\Scripts\Get-FailedLogonReport.ps1. Change $ReportDir to a path you control. The comments mark the two non-obvious parts — the event XML parsing and the empty-result handling.

#Requires -Version 5.1
# Collect failed-logon (Event ID 4625) events from the last 24 hours
# and write a dated HTML report. Read-only against the Security log.

$ReportDir  = 'C:\Reports\FailedLogons'      # <-- change to your path
$Now        = Get-Date
$Start      = $Now.AddDays(-1)
$ReportFile = Join-Path $ReportDir ("FailedLogons_{0:yyyy-MM-dd}.html" -f $Now)

if (-not (Test-Path $ReportDir)) {
    New-Item -Path $ReportDir -ItemType Directory -Force | Out-Null
}

# FilterHashtable is the fast, server-side way to query the log.
$filter = @{
    LogName   = 'Security'
    Id        = 4625
    StartTime = $Start
}

# No matching events makes Get-WinEvent throw; swallow that and handle it below.
$events = Get-WinEvent -FilterHashtable $filter -ErrorAction SilentlyContinue

if (-not $events) {
    "<h2>No failed logon events (4625) between $Start and $Now</h2>" |
        Out-File -FilePath $ReportFile -Encoding utf8
    return
}

$rows = foreach ($e in $events) {
    # The friendly fields for 4625 live in the event's EventData, read via XML by name.
    [xml]$xml = $e.ToXml()
    $d = @{}
    foreach ($item in $xml.Event.EventData.Data) { $d[$item.Name] = $item.'#text' }

    [pscustomobject]@{
        TimeCreated   = $e.TimeCreated
        TargetUser    = $d['TargetUserName']
        TargetDomain  = $d['TargetDomainName']
        LogonType     = $d['LogonType']
        SourceIP      = $d['IpAddress']
        Workstation   = $d['WorkstationName']
        Status        = $d['Status']
        SubStatus     = $d['SubStatus']
    }
}

$rows |
    Sort-Object TimeCreated |
    ConvertTo-Html -Title ("Failed Logons {0:yyyy-MM-dd}" -f $Now) `
        -PreContent ("<h2>Failed logon events (4625): {0} between {1} and {2}</h2>" -f $rows.Count, $Start, $Now) |
    Out-File -FilePath $ReportFile -Encoding utf8

A note on the field names: I read them out of the event XML by their Name attribute (TargetUserName, IpAddress, and so on) rather than by position, because positional indexes into the Properties array shift between event schemas and are easy to get wrong. The names above are the standard EventData fields Microsoft documents for 4625 — if you want to add or rename columns, confirm the exact field name on the "4625(F): An account failed to log on" page under the Windows security auditing reference on Microsoft Learn before you trust it.

Reading the results

Two columns need translation:

  • LogonType — a number. The common ones are 2 (interactive/console), 3 (network, e.g. SMB), 10 (RemoteInteractive/RDP). The full list is on the same 4625 documentation page.
  • SubStatus — the failure reason as a hex code. 0xC000006A is a wrong password and 0xC0000064 is a user name that doesn't exist; the complete table is in Microsoft's docs. I'm deliberately not pasting the whole list here — check the reference rather than trusting a half-remembered code.

Schedule it as a daily task

Run this once, from an elevated PowerShell session, to register the task. It runs the script every morning as SYSTEM.

$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Get-FailedLogonReport.ps1"'

$trigger = New-ScheduledTaskTrigger -Daily -At 6:00am

# SYSTEM can read the local Security log and needs no stored password.
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' `
    -LogonType ServiceAccount -RunLevel Highest

Register-ScheduledTask -TaskName 'Daily Failed Logon Report' `
    -Action $action -Trigger $trigger -Principal $principal `
    -Description 'Collects Event ID 4625 into a daily HTML report'

If you'd rather have the report emailed, be aware that Send-MailMessage still exists but Microsoft has formally deprecated it and advises against new use. The mainstream, low-friction path is what this guide does: write the report to a folder or file share and point people at it, or let a monitoring tool watch the folder. If you do need mail, that's a separate decision — pick your organisation's supported method rather than bolting a deprecated cmdlet onto this job.

Verify it worked

Test the script by hand first, in an elevated session:

& 'C:\Scripts\Get-FailedLogonReport.ps1'
Get-ChildItem 'C:\Reports\FailedLogons'   # confirm a dated .html file appeared

Open the HTML file and sanity-check the rows against what you know happened. If it's empty and you expected failures, make sure logon-failure auditing is actually on:

auditpol /get /subcategory:"Logon"

Confirm the scheduled task registered and can run:

Get-ScheduledTask -TaskName 'Daily Failed Logon Report'
Start-ScheduledTask -TaskName 'Daily Failed Logon Report'   # run it now
Get-ScheduledTaskInfo -TaskName 'Daily Failed Logon Report' # LastTaskResult should be 0

A LastTaskResult of 0 means the last run exited cleanly. If it's non-zero, run the script interactively as above — you'll usually see a permissions error (the account isn't an admin or Event Log Reader) or a bad $ReportDir path.

Undo

This creates two things: a task and report files. To remove them:

Unregister-ScheduledTask -TaskName 'Daily Failed Logon Report' -Confirm:$false

Then delete the report folder if you don't want to keep the history:

Remove-Item 'C:\Reports\FailedLogons' -Recurse   # will prompt before deleting contents

Removing the task and reports leaves the Security log and audit policy exactly as they were — this whole procedure only ever read the log, so there's nothing else to reverse.

Written by
Ketan Aagja

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.