Daily Report of Off-Hours Logon Failures Across the Domain
Before you run this
This guide builds a scheduled PowerShell job that reads the Security event log on every domain controller, pulls failed-logon events (Event ID 4625) from the previous day, keeps only the ones that happened outside your defined business hours (and optionally on weekends), and writes them to a dated CSV. Optionally it emails the file. It is a reporting job — it reads logs and writes a report file. It does not change Active Directory, disable accounts, or touch any policy.
- Privileges: The script itself doesn't need local administrator on your workstation, but the account that runs it needs permission to read the Security log on each DC. The standard, least-privilege way to grant that is membership in the Event Log Readers group — it does not require Domain Admin. It also needs the ActiveDirectory PowerShell module (RSAT) to enumerate DCs, and Remote Event Log Management must be allowed through the DC firewalls for remote queries to work.
- Test first: Read the script before you run it. Run it interactively, against one DC, with a wide time window, and confirm the CSV looks right before you schedule it or point it at every DC. Don't register the scheduled task until the manual run is clean.
- Reversibility: Nothing here is destructive, so there is little to undo — but see the last section for how to remove the scheduled task and clean up report files if you want to back the whole thing out.
Assumptions
I'm writing this for a Windows Server 2019/2022 Active Directory domain, running the job from a domain-joined management server or admin workstation with RSAT installed and Windows PowerShell 5.1. It works under PowerShell 7 too; the only version-sensitive piece is the email step, noted below. Replace every obvious placeholder — EXAMPLE\svc-logonreport, C:\Reports\LogonFailures, C:\Scripts\..., domain and SMTP values — with your own.
A note on which events you're actually collecting
Event 4625 is logged on the machine where the logon was attempted. On a DC, that means failed logons made directly against the DC. A bad password typed at a workstation generates 4625 on that workstation, and on the DC it shows up instead as 4771 (Kerberos pre-authentication failed) or 4776 (NTLM credential validation). So if your goal is domain-wide credential-failure visibility, you'll want to add 4771 and 4776 to the query. I keep the example on 4625 because its data fields are stable and well documented; 4771 and 4776 carry different field names, so check the Microsoft Learn pages for those two event IDs before you parse them, rather than guessing the field layout. Extending the ID list is easy; mapping the fields correctly is the part to get right.
The script
Save this as C:\Scripts\OffHoursLogonReport.ps1:
#Requires -Modules ActiveDirectory
# Read-only daily report of off-hours failed logons (Event ID 4625) from all DCs.
# ---- Settings you edit ----
$BusinessStartHour = 7 # first "normal" hour (07:00)
$BusinessEndHour = 19 # first OFF-hour (19:00) — anything at/after this is off-hours
$TreatWeekendOffHours = $true
$ReportDir = 'C:\Reports\LogonFailures' # replace with your path
# ---------------------------
# Window: all of yesterday, local time
$start = (Get-Date).Date.AddDays(-1)
$end = (Get-Date).Date
$dcs = (Get-ADDomainController -Filter *).HostName
$results = foreach ($dc in $dcs) {
try {
$events = Get-WinEvent -ComputerName $dc -FilterHashtable @{
LogName = 'Security'
ID = 4625
StartTime = $start
EndTime = $end
} -ErrorAction Stop
}
catch {
# Get-WinEvent throws when nothing matches — treat that as "none" and continue
Write-Warning "$dc : $($_.Exception.Message)"
continue
}
foreach ($e in $events) {
$t = $e.TimeCreated
$isWeekend = $t.DayOfWeek -in 'Saturday','Sunday'
$isOffHour = ($t.Hour -lt $BusinessStartHour) -or ($t.Hour -ge $BusinessEndHour)
if (-not ($isOffHour -or ($TreatWeekendOffHours -and $isWeekend))) { continue }
# Read named fields from the event XML so we don't depend on array positions
$xml = [xml]$e.ToXml()
$data = @{}
foreach ($d in $xml.Event.EventData.Data) { $data[$d.Name] = $d.'#text' }
[pscustomobject]@{
Time = $t
DC = $dc
Account = $data['TargetUserName']
AcctDomain = $data['TargetDomainName']
Workstation = $data['WorkstationName']
SourceIP = $data['IpAddress']
LogonType = $data['LogonType']
}
}
}
if (-not (Test-Path $ReportDir)) { New-Item -ItemType Directory -Path $ReportDir | Out-Null }
$stamp = (Get-Date).ToString('yyyy-MM-dd')
$csv = Join-Path $ReportDir "offhours-logon-failures-$stamp.csv"
$results | Sort-Object Time | Export-Csv -Path $csv -NoTypeInformation -Encoding UTF8
"$(@($results).Count) off-hours logon failures written to $csv"
Reading the fields by their Name attribute (via ToXml()) rather than by Properties[7] matters: the array positions are easy to get wrong and silently mislabel columns, whereas TargetUserName, WorkstationName, and IpAddress are stable names for 4625.
Emailing it (optional)
If you want the CSV delivered rather than just dropped on disk, append a send step. On Windows PowerShell 5.1 the built-in Send-MailMessage still works, though Microsoft has marked it deprecated — fine for an internal relay, but don't build anything long-lived on it:
if (@($results).Count -gt 0) {
Send-MailMessage -From 'reports@example.com' -To 'secops@example.com' `
-Subject "Off-hours logon failures - $stamp ($(@($results).Count))" `
-Body 'Report attached.' -Attachments $csv `
-SmtpServer 'smtp.example.com' # replace with your relay
}
Grant the service account read access
Create a dedicated service account (for example EXAMPLE\svc-logonreport) and add it to the domain's built-in Event Log Readers group, which grants read access to the DCs' Security logs without making it an admin:
Add-ADGroupMember -Identity 'Event Log Readers' -Members 'svc-logonreport'
If remote queries still fail with access/RPC errors, confirm the Remote Event Log Management firewall rules are enabled on the DCs (Windows Defender Firewall → Inbound Rules), and that nothing is blocking RPC between the management host and the DCs.
Schedule it
Register a daily task that runs a little after midnight so "yesterday" is complete. The cleanest way to store credentials safely is to create the task in the Task Scheduler GUI (so Windows protects the password) or to use a group Managed Service Account (gMSA). If you prefer to script it:
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\OffHoursLogonReport.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 6:30am
Register-ScheduledTask -TaskName 'Off-Hours Logon Failure Report' `
-Action $action -Trigger $trigger `
-User 'EXAMPLE\svc-logonreport' -Password 'REPLACE-ME' -RunLevel Limited
-RunLevel Limited is deliberate — this job needs log-read rights, not elevation. Putting a plaintext password on the command line is the weak point of the scripted approach; for anything you intend to keep, set it up through the GUI or a gMSA instead.
Verify it worked
Run the script by hand first and read the output line and the CSV:
powershell.exe -NoProfile -File C:\Scripts\OffHoursLogonReport.ps1
Import-Csv 'C:\Reports\LogonFailures\offhours-logon-failures-<date>.csv' | Format-Table
Spot-check one row against the DC it came from — open Event Viewer → Security on that DC, find the matching 4625 by timestamp, and confirm the account and source line up. Then confirm the scheduler is firing:
Get-ScheduledTaskInfo -TaskName 'Off-Hours Logon Failure Report'
LastRunTime should advance each day and LastTaskResult should read 0. Task Scheduler's History tab shows the same thing in the GUI.
An empty CSV is a valid result — it means there were no off-hours failures. Verify the job is still running (via Get-ScheduledTaskInfo) rather than assuming silence means "nothing happened."
Undo / roll back
There's nothing to reverse in AD, but to remove the automation:
Unregister-ScheduledTask -TaskName 'Off-Hours Logon Failure Report' -Confirm:$false
Delete the accumulated reports in C:\Reports\LogonFailures if you no longer need them, and — if the service account was created solely for this — remove it from Event Log Readers and disable it. Removing the scheduled task stops all future runs immediately; existing CSVs are just files and can be kept or deleted at will.
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
Automate a Daily Failed-Logon (4625) Report with PowerShell
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.
Email a Daily Windows Event Log Error Summary with PowerShell
This guide builds a small PowerShell script that reads the last 24 hours of Critical and Error events from the System and Application logs, writes them to an HTML file, and emails that file to you. It's a read-only report: it does not clear, modify, or delete any event log. The only things it creates on the system are the HTML report files in a folder you choose and — in the last section — a scheduled task.
Find AD Security Groups With Cross-Domain Members for Cleanup
In a multi-domain forest, group membership sprawls across domain boundaries over the years: a universal or domain-local group in corp ends up holding users from emea , or Foreign Security Principals (FSPs) from a trusted external forest that nobody remembers adding. Before you clean any of that up, you need an accurate inventory. This guide gives you a read-only report of every group in one domain that contains members from another domain, plus a carefully gated way to remove the ones you've decided to drop.
Automate a Weekly NTFS Share Permission Drift Audit
Share permissions drift. Someone grants a contractor "just temporary" access, a helpdesk ticket adds a group to a folder, an app installer rewrites an ACL — and six months later nobody remembers why Everyone can write to the finance share. This guide builds a simple, read-only baseline of your NTFS share ACLs and a weekly job that tells you exactly what has changed since.


