Published on

How to Find User Logon History in Active Directory (PowerShell Script)

Active Directory only ever remembers a user's most recent logon — there's no attribute anywhere that stores a history. If you need to answer "when did this person actually log in over the last week" for an investigation, an offboarding check, or just confirming a suspicious login report, you have to go to the Security event log instead, and check every domain controller, because logons aren't consolidated anywhere.

The quick answer

Get-WinEvent -ComputerName <DC-name> -FilterHashtable @{LogName='Security'; Id=4624} |
    Where-Object { $_.Properties[5].Value -eq 'jdoe' }

That's one DC. In a multi-DC environment you'd need to repeat it for every controller and merge the results by hand.

A more useful reporting script

#requires -Modules ActiveDirectory
<#
.SYNOPSIS
    Reports a user's logon history from the Security event log on every domain controller.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$SamAccountName,
    [int]$Hours = 24,
    [string]$OutputCsv
)

Import-Module ActiveDirectory -ErrorAction Stop

$logonTypes = @{
    2 = 'Interactive'; 3 = 'Network'; 4 = 'Batch'; 5 = 'Service'; 7 = 'Unlock'
    8 = 'NetworkCleartext'; 9 = 'NewCredentials'; 10 = 'RemoteInteractive'; 11 = 'CachedInteractive'
}

$domainControllers = Get-ADDomainController -Filter *
$startTime = (Get-Date).AddHours(-$Hours)
$results = New-Object System.Collections.Generic.List[object]

foreach ($dc in $domainControllers) {
    Write-Host "Checking $($dc.HostName) for logon events since $startTime ..." -ForegroundColor Cyan

    $filterHashtable = @{ LogName = 'Security'; Id = 4624; StartTime = $startTime }

    try {
        $events = Get-WinEvent -ComputerName $dc.HostName -FilterHashtable $filterHashtable -ErrorAction Stop
    } catch {
        if ($_.Exception -is [System.Diagnostics.Eventing.Reader.EventLogNotFoundException] -or
            $_.Exception.Message -like 'No events were found*') {
            Write-Verbose "No logon events found on $($dc.HostName) in the given window."
            continue
        }
        Write-Warning "Could not query $($dc.HostName): $($_.Exception.Message)"
        continue
    }

    foreach ($event in $events) {
        $targetUser = $event.Properties[5].Value
        if ($targetUser -ne $SamAccountName) { continue }

        $logonTypeCode = [int]$event.Properties[8].Value

        $results.Add([PSCustomObject]@{
            TimeCreated     = $event.TimeCreated
            User            = $targetUser
            LogonType       = $logonTypes[$logonTypeCode]
            WorkstationName = $event.Properties[11].Value
            IpAddress       = $event.Properties[18].Value
            RecordedOnDC    = $dc.HostName
        })
    }
}

if ($results.Count -eq 0) {
    Write-Host "No logon events found for $SamAccountName in the last $Hours hour(s)." -ForegroundColor Yellow
    return
}

$sorted = $results | Sort-Object TimeCreated -Descending

Write-Host "Found $(@($sorted).Count) logon event(s) for $SamAccountName." -ForegroundColor Cyan
$sorted | Format-Table TimeCreated, LogonType, WorkstationName, IpAddress, RecordedOnDC -AutoSize

if ($OutputCsv) {
    $sorted | Export-Csv -Path $OutputCsv -NoTypeInformation
    Write-Host "Exported results to $OutputCsv" -ForegroundColor Green
}

The LogonType column translates the raw numeric code (2, 3, 10, etc.) into something readable — Interactive, Network, RemoteInteractive — so you can immediately tell an RDP session apart from a file share access without memorizing Microsoft's event schema.

The gap this script doesn't close

Event 4624 on a domain controller reflects domain authentication against that DC — Kerberos ticket requests, NTLM validation — which is a good proxy for "this user authenticated somewhere" but isn't the same as an interactive logon at a specific workstation. For a complete picture of exactly where someone physically sat down and typed their password, you'd also need 4624 on the relevant workstations, which a DC-only script like this one can't see. Combine this with your endpoint logging or SIEM if that level of detail matters for an investigation.

Where the manual approach runs out of road

A one-off script is fine for a single check. It starts to hurt once you actually need to run this regularly:

  • No scheduling. Cron/Task Scheduler can run the script, but now you own the scheduling, the credentials it runs as, and what happens when it silently fails.
  • No history. A CSV export is a snapshot. Was this the same five accounts as last month, or a growing list? A single export can't tell you.
  • No distribution. Getting the report to the right people (security, IT ops, compliance) on a schedule means building that plumbing yourself.
  • Multi-domain/multi-forest pain. Run it once per domain, reconcile the results yourself, and hope naming/OU conventions are consistent across all of them.
  • No alerting. If something changes unexpectedly between runs — an account re-enabled, a privileged group gaining a member — nothing tells you until you happen to run the script again.

None of that is a PowerShell problem. It's what turns a script into a product.

What SysFlint AD does instead

Our SysFlint AD runs the same kind of discovery shown above automatically, on a schedule, entirely inside your own network. No agents on domain controllers, no data leaving your environment. It's free, forever.

If you're currently doing this with a script on a scheduled task, here's the honest comparison between the two, or go straight to the download page.

Get this script, and the rest of them

The script above is part of awesome-it-scripts, SysFlint's free, open-source library of PowerShell scripts for Active Directory and other IT-admin tasks — no signup, no catch, MIT licensed. Grab this one directly from active-directory/Get-ADUserLogonHistory.ps1, or browse the whole thing.

Find every disabled/locked-out/stale/privileged-account script we've published (plus the FAQ that goes with each one) in the repo's active-directory folder. Star it if it's useful — new scripts land there regularly.