How It Works
Why DCs and not the user object
AD replicates lastLogonTimestamp but strips the source machine entirely. The only place that information exists is in DC Security logs, specifically event 4768 (Kerberos TGT request, contains the client IP) and 4776 (NTLM validation, contains the workstation name directly). The script queries all DCs by default because a user can authenticate against any one of them.
Log retention check
Before searching anything, the script pulls the oldest event from each DC’s Security log and tells you exactly how far back the data goes. On a busy DC that can be a few hours. This matters because “no results” and “log already rolled” look identical if you don’t check first.
Event collection and name resolution
For each user, it builds a list of name variants (mixed case, UPN form) because Get-WinEvent’s server-side Data filter is case-sensitive and the log contains whatever the client sent. It then pulls 4768 and 4776 events (plus 4769 if you pass -IncludeServiceTickets) and extracts the source machine. For 4768, that means reverse-resolving the client IP via DNS. For 4776, the workstation name is in the event directly, though sometimes prefixed with , which the script strips.
Filtering out noise
Events where neither a name nor a resolvable IP is present get bucketed as (no source recorded). These are service accounts, scheduled tasks, or logins local to the DC itself. They’re shown in the summary table but excluded from the answer, because they can never tell you where a person sat down.
Rolling up and picking an answer
After collecting raw events, the script groups by computer and sorts by most recent activity. The top result with an actual machine name is the answer. Everything else is shown in a table so you can see the full picture.
Optional workstation verification
With -VerifyOnComputer, the script takes the top candidate and queries that machine’s own Security log for a 4624 event (interactive logon, logon types 2, 7, 10, 11). A confirmed match means you have the DC side and the workstation side agreeing. If the machine is offline or its log has rolled, you get a warning rather than a crash.
Usage
Requirements
- RSAT ActiveDirectory module (
#Requires -Modules ActiveDirectorywill stop the script cleanly if it’s missing) - Read access to the Security log on each DC. Domain Admin works. So does adding the account to the local Event Log Readers group on each DC, which is the lower-privilege option.
- For
-VerifyOnComputer, remote event log access to the target workstation (firewall rule: Remote Event Log Management).
Basic lookup
.Get-UserLastLogonComputer.ps1 -Username user2Code language: PowerShell (powershell)
Narrow the search window and verify on the machine
.Get-UserLastLogonComputer.ps1 -Username user2 -DaysBack 3 -VerifyOnComputerCode language: PowerShell (powershell)
Multiple users, export to CSV
.Get-UserLastLogonComputer.ps1 -Username user2, user3 -CsvPath C:StuffLastLogon.csvCode language: PowerShell (powershell)
Target a specific DC instead of all of them
.Get-UserLastLogonComputer.ps1 -Username user2 -DomainController MAIL01Code language: PowerShell (powershell)
Useful when you already know which DC the user authenticated against, or when you want a faster run at the cost of completeness.
Skip passing -Username entirely
Edit $DefaultUsers near the top of the script and just run it with no parameters. Handy for recurring checks on a known set of accounts.
Key parameters
| Parameter | Default | Notes |
|---|---|---|
-Username |
$DefaultUsers |
One or more sAMAccountNames |
-DaysBack |
14 | Capped by actual log retention |
-DomainController |
All DCs | Limit to specific hosts |
-IncludeServiceTickets |
Off | Adds 4769 events, more data, more noise |
-MaxEventsPerDC |
2000 | Raise this if results look cut off |
-VerifyOnComputer |
Off | Cross-checks result on the workstation |
-CsvPath |
None | Exports full result set |
-PassThru |
Off | Emits result objects to the pipeline |
Caveats
Log retention is the real constraint
The -DaysBack parameter sets your intent. The DC’s actual log retention sets the reality. A busy DC running default settings may only have a few hours of Security log history. Check the retention horizon the script prints before trusting a “not found” result.
The event cap can silently truncate results
-MaxEventsPerDC defaults to 2000. If a user has more auth events than that on a single DC in the search window, you may miss older ones. The script warns you when the cap is hit. Raise it if you’re searching high-volume accounts or wide time windows.
DNS reverse lookup can return the wrong name
The script uses PTR records to resolve IPs from 4768 events. If your reverse DNS is stale or missing entries, you’ll get the IP address as the computer name instead, or nothing at all. The ViaDNS field in the raw records tells you which results came from reverse lookup vs. a name embedded in the event.
NTLM events without a workstation name
A 4776 event where the workstation field is - or blank is counted as (no source recorded) and excluded from the answer. This happens for service account logons, scheduled tasks, and any auth that goes through the DC’s own loopback. If every event for a user falls into this bucket, the script will tell you so explicitly.
-VerifyOnComputer requires the workstation to be online and accessible
If the machine is off, firewalled, or its own Security log has rolled since the logon happened, the verification step returns a warning and the main result still stands unconfirmed. It’s a best-effort check, not a gate.
The Security log filter is case-sensitive server-side
Get-WinEvent’s -FilterHashtable with a Data key does a case-sensitive match on the DC. The script generates six name variants to work around this, but if your environment has unusual account name formats (non-ASCII characters, for example), you may need to add more variants to Get-NameVariants.
Querying all DCs takes time
With a large number of DCs and a wide search window, this script will take a while. Use -DomainController to target specific hosts when speed matters more than completeness.
Full Script
#Requires -Modules ActiveDirectory
<#
.SYNOPSIS
Finds the computer a domain user most recently logged on from.
.DESCRIPTION
Active Directory does NOT store "last computer used" anywhere on the user
object. The only authoritative record is the authentication traffic that
domain controllers log when the user signs in at a workstation.
This script queries the Security event log on every domain controller (or a
subset you specify) for that user's authentication events, resolves them to
computer names, and reports the most recent one.
Event sources used:
4768 Kerberos TGT requested - fires on every domain logon. Records the
client IP, which is reverse-resolved to a
computer name.
4776 NTLM credential validated - records the workstation NAME directly.
Fires for NTLM / cached / legacy logons.
4769 Kerberos service ticket - optional (-IncludeServiceTickets). Higher
volume, but catches ongoing activity after
the TGT was already issued.
IMPORTANT: this can only see as far back as the DC Security logs retain. On a
busy DC that can be a matter of hours. The script reports the oldest event
present in each DC's log so you know how far back the answer is trustworthy.
.PARAMETER Username
One or more sAMAccountNames to look up. Defaults to the $DefaultUsers array
in the Configuration section below.
.PARAMETER DaysBack
How far back to search. Default 14. Capped in practice by log retention.
.PARAMETER DomainController
Specific DC(s) to query. Default: every DC in the domain. A user can
authenticate against any DC, so querying all of them is the reliable option.
.PARAMETER IncludeServiceTickets
Also collect 4769 events. More data points, more noise.
.PARAMETER MaxEventsPerDC
Safety cap on events pulled per DC, per user. Default 2000.
.PARAMETER VerifyOnComputer
After identifying the top candidate, query THAT machine's own Security log
for the matching 4624 interactive logon, to confirm the result at the source.
Requires remote event log access to the workstation.
.PARAMETER CsvPath
Optional path to export the full result set.
.PARAMETER PassThru
Also emit the result objects to the pipeline (off by default, so the console
report isn't duplicated by a raw object dump).
.EXAMPLE
.Get-UserLastLogonComputer.ps1 -Username user2
.EXAMPLE
.Get-UserLastLogonComputer.ps1 -Username user2 -DaysBack 3 -VerifyOnComputer
.EXAMPLE
.Get-UserLastLogonComputer.ps1 -Username user2,user3 -CsvPath C:StuffLastLogon.csv
.NOTES
Requires the ActiveDirectory module (RSAT) and rights to read the Security
log on the domain controllers - Domain Admin, or membership in the local
"Event Log Readers" group on each DC.
#>
param(
[Parameter(Position = 0)]
[string[]]$Username,
[int]$DaysBack = 14,
[string[]]$DomainController,
[switch]$IncludeServiceTickets,
[int]$MaxEventsPerDC = 2000,
[switch]$VerifyOnComputer,
[string]$CsvPath,
[switch]$PassThru
)
# =============================================================================
# CONFIG - edit this list if you'd rather run without passing -Username
# =============================================================================
$DefaultUsers = @(
"user2"
# "user3"
)
# Logon types treated as "a person sat at this machine" during -VerifyOnComputer
$InteractiveLogonTypes = @(2, 7, 10, 11) # 2=local, 7=unlock, 10=RDP, 11=cached
# Label for events that carry no usable source - 4776 with Workstation "-", or
# 4768 from the DC's own loopback. These are service / scheduled-task / local-to-DC
# authentications: real, but they do not name a computer, so they can never be the
# answer to "where was this user". Shown in the table, excluded from the pick.
$NoSourceLabel = "(no source recorded)"
# =============================================================================
# HELPERS
# =============================================================================
function Write-Info { param($m) Write-Host $m -ForegroundColor Cyan }
function Write-Ok { param($m) Write-Host $m -ForegroundColor Green }
function Write-Warn2 { param($m) Write-Host $m -ForegroundColor Yellow }
function Write-Err { param($m) Write-Host $m -ForegroundColor Red }
$script:DnsCache = @{}
function Get-EventDataHash {
# Flattens an event's EventData section into a name -> value hashtable.
param($Event)
$hash = @{}
try {
$xml = [xml]$Event.ToXml()
foreach ($d in $xml.Event.EventData.Data) {
if ($d.Name) { $hash[$d.Name] = $d.'#text' }
}
}
catch {
# Malformed / schema-less event - skip it rather than kill the run
}
return $hash
}
function ConvertTo-CleanIP {
# Strips the IPv4-mapped IPv6 prefix and filters out non-addresses.
param([string]$IPAddress)
if ([string]::IsNullOrWhiteSpace($IPAddress)) { return $null }
$ip = $IPAddress -replace '^::ffff:', ''
if ($ip -in @('-', '::1', '127.0.0.1')) { return $null }
return $ip
}
function Resolve-IPToName {
# Reverse-DNS with a session cache. Returns a short, upper-cased name.
param([string]$IPAddress)
if ([string]::IsNullOrWhiteSpace($IPAddress)) { return $null }
if ($script:DnsCache.ContainsKey($IPAddress)) { return $script:DnsCache[$IPAddress] }
$name = $null
try {
$ptr = Resolve-DnsName -Name $IPAddress -Type PTR -QuickTimeout -ErrorAction Stop
$name = ($ptr | Where-Object { $_.NameHost } | Select-Object -First 1).NameHost
}
catch {
try { $name = [System.Net.Dns]::GetHostEntry($IPAddress).HostName } catch { }
}
if ($name) { $name = $name.Split('.')[0].ToUpper() }
$script:DnsCache[$IPAddress] = $name
return $name
}
function Get-NameVariants {
# The Security log stores the account name exactly as the client sent it, and
# Get-WinEvent's server-side Data filter is CASE-SENSITIVE. Generate the common
# casings plus the UPN form, because 4769 logs [email protected].
param([string]$Sam, [string]$DnsRoot)
$v = @(
$Sam
$Sam.ToLower()
$Sam.ToUpper()
"$Sam@$DnsRoot"
"$($Sam.ToLower())@$($DnsRoot.ToLower())"
"$($Sam.ToUpper())@$($DnsRoot.ToUpper())"
)
return ($v | Select-Object -Unique)
}
function Get-SecurityEvents {
# Wraps Get-WinEvent so that "no matches" is a normal outcome, not an error.
param(
[string]$Computer,
[hashtable]$Filter,
[int]$MaxEvents
)
try {
return @(Get-WinEvent -ComputerName $Computer -FilterHashtable $Filter `
-MaxEvents $MaxEvents -ErrorAction Stop)
}
catch {
if ($_.Exception.Message -match 'No events were found') { return @() }
Write-Warn2 " [!] $Computer : $($_.Exception.Message)"
return @()
}
}
# =============================================================================
# SETUP
# =============================================================================
if (-not $Username -or $Username.Count -eq 0) { $Username = $DefaultUsers }
Write-Info "============================================================"
Write-Info " Last Logon Computer Lookup"
Write-Info "============================================================"
try {
$Domain = Get-ADDomain -ErrorAction Stop
$DnsRoot = $Domain.DNSRoot
}
catch {
Write-Err "Could not contact Active Directory: $_"
return
}
if ($DomainController) {
$DCs = $DomainController
}
else {
try {
$DCs = @(Get-ADDomainController -Filter * -ErrorAction Stop |
Sort-Object HostName |
Select-Object -ExpandProperty HostName)
}
catch {
Write-Err "Could not enumerate domain controllers: $_"
return
}
}
if (-not $DCs -or $DCs.Count -eq 0) {
Write-Err "No domain controllers to query."
return
}
$StartTime = (Get-Date).AddDays(-[math]::Abs($DaysBack))
$EventIds = @(4768, 4776)
if ($IncludeServiceTickets) { $EventIds += 4769 }
Write-Host ""
Write-Host " Domain : $DnsRoot"
Write-Host " DCs to query : $($DCs.Count) ($($DCs -join ', '))"
Write-Host " Window : $($StartTime.ToString('yyyy-MM-dd HH:mm')) -> now ($DaysBack days)"
Write-Host " Event IDs : $($EventIds -join ', ')"
Write-Host ""
# --- How far back does each DC's Security log actually reach? -----------------
# This is the real limit on the answer, and it is usually much shorter than
# -DaysBack. Report it up front so "not found" isn't mistaken for "never".
Write-Info "--- Security log retention horizon ---"
$LogHorizon = @{}
foreach ($dc in $DCs) {
try {
$oldest = Get-WinEvent -ComputerName $dc -LogName Security -MaxEvents 1 -Oldest -ErrorAction Stop
$LogHorizon[$dc] = $oldest.TimeCreated
$age = [math]::Round(((Get-Date) - $oldest.TimeCreated).TotalHours, 1)
$msg = " {0,-30} back to {1} ({2} h)" -f $dc, $oldest.TimeCreated.ToString('yyyy-MM-dd HH:mm'), $age
if ($oldest.TimeCreated -gt $StartTime) { Write-Warn2 "$msg <- shorter than search window" }
else { Write-Host $msg }
}
catch {
$LogHorizon[$dc] = $null
Write-Warn2 (" {0,-30} unreadable: {1}" -f $dc, $_.Exception.Message)
}
}
Write-Host ""
# =============================================================================
# MAIN
# =============================================================================
$AllResults = @()
foreach ($u in $Username) {
# --- Resolve the account so we search on AD's canonical sAMAccountName ---
try {
$ADUser = Get-ADUser -Identity $u -Properties DisplayName, LastLogonDate, Enabled -ErrorAction Stop
}
catch {
Write-Err "User not found in AD: $u"
continue
}
$Sam = $ADUser.SamAccountName
$Variants = Get-NameVariants -Sam $Sam -DnsRoot $DnsRoot
$Display = if ($ADUser.DisplayName) { " ($($ADUser.DisplayName))" } else { "" }
Write-Info "============================================================"
Write-Info " $Sam$Display"
Write-Info "============================================================"
$adStamp = if ($ADUser.LastLogonDate) { $ADUser.LastLogonDate.ToString('yyyy-MM-dd HH:mm') } else { 'never / not replicated' }
Write-Host " Enabled : $($ADUser.Enabled)"
Write-Host " AD LastLogonDate : $adStamp (tells you WHEN, never WHERE)"
Write-Host ""
# --- Collect raw authentication events from every DC ---------------------
$Records = @()
$i = 0
foreach ($dc in $DCs) {
$i++
Write-Progress -Activity "Searching Security logs for $Sam" -Status $dc `
-PercentComplete (($i / $DCs.Count) * 100)
$filter = @{
LogName = 'Security'
ID = $EventIds
StartTime = $StartTime
Data = $Variants
}
$events = Get-SecurityEvents -Computer $dc -Filter $filter -MaxEvents $MaxEventsPerDC
foreach ($evt in $events) {
$d = Get-EventDataHash -Event $evt
# The Data filter matches ANY data field, so confirm this event is
# actually ABOUT our user, not one where they were merely the subject.
$target = $d['TargetUserName']
if (-not $target) { continue }
if ($Variants -notcontains $target) { continue }
$ip = ConvertTo-CleanIP $d['IpAddress']
# 4624 uses WorkstationName, 4776 uses Workstation, 4768/4769 neither.
$wks = $d['WorkstationName']
if (-not $wks) { $wks = $d['Workstation'] }
# 4776 logs the workstation as "NAME" about half the time, which would
# otherwise split one machine across two rows.
if ($wks) { $wks = ($wks -replace '^\', '').Trim().ToUpper() }
if ($wks -in @('-', '')) { $wks = $null }
# No name in the event? Reverse-resolve the client IP.
$resolved = $false
if (-not $wks -and $ip) {
$wks = Resolve-IPToName $ip
$resolved = $true
}
# Still nothing: fall back to the bare IP, then to the no-source bucket.
$named = $true
if (-not $wks) {
if ($ip) { $wks = $ip }
else { $wks = $NoSourceLabel; $named = $false }
}
$Records += [PSCustomObject]@{
User = $Sam
Computer = $wks
IPAddress = $ip
Time = $evt.TimeCreated
EventId = $evt.Id
DC = $dc
ViaDNS = $resolved
Named = $named
}
}
if ($events.Count -ge $MaxEventsPerDC) {
Write-Warn2 " [!] $dc hit the $MaxEventsPerDC event cap - raise -MaxEventsPerDC if results look truncated."
}
}
Write-Progress -Activity "Searching Security logs for $Sam" -Completed
if ($Records.Count -eq 0) {
Write-Warn2 " No authentication events found for $Sam in the last $DaysBack days."
Write-Warn2 " That means 'not in the retained logs' - check the retention horizon above."
Write-Host ""
continue
}
# --- Roll up to one row per computer -------------------------------------
$Summary = $Records | Group-Object Computer | ForEach-Object {
$g = $_.Group | Sort-Object Time -Descending
[PSCustomObject]@{
User = $Sam
Computer = $_.Name
IPAddress = ($g | Where-Object { $_.IPAddress } | Select-Object -First 1 -ExpandProperty IPAddress)
LastSeen = $g[0].Time
FirstSeen = $g[-1].Time
Events = $_.Count
EventIds = (($g | Select-Object -ExpandProperty EventId | Sort-Object -Unique) -join ',')
SeenOnDC = (($g | Select-Object -ExpandProperty DC | Sort-Object -Unique) -join ',')
Named = [bool]($g[0].Named)
}
} | Sort-Object LastSeen -Descending
# The answer can only come from a row that actually names a machine.
$Top = $Summary | Where-Object { $_.Named } | Select-Object -First 1
if ($Top) {
Write-Ok " ANSWER: $($Top.Computer)"
Write-Host " IP : $($Top.IPAddress)"
Write-Host " Last seen : $($Top.LastSeen.ToString('yyyy-MM-dd HH:mm:ss'))"
Write-Host " Source : event $($Top.EventIds) on $($Top.SeenOnDC)"
}
else {
Write-Warn2 " ANSWER: none - every event for $Sam was $NoSourceLabel."
Write-Warn2 " That pattern means service / scheduled-task / DC-local authentication,"
Write-Warn2 " not an interactive logon at a workstation."
}
Write-Host ""
Write-Host " All sources seen in window:"
$Summary | Format-Table Computer, IPAddress, LastSeen, FirstSeen, Events, EventIds -AutoSize |
Out-String | Write-Host
# --- Optional: confirm at the workstation itself -------------------------
if ($VerifyOnComputer -and $Top) {
Write-Info " --- Verifying on $($Top.Computer) (its own 4624 log) ---"
$vFilter = @{
LogName = 'Security'
ID = 4624
StartTime = $StartTime
Data = $Variants
}
$vEvents = Get-SecurityEvents -Computer $Top.Computer -Filter $vFilter -MaxEvents $MaxEventsPerDC
$vHits = foreach ($evt in $vEvents) {
$d = Get-EventDataHash -Event $evt
if ($Variants -notcontains $d['TargetUserName']) { continue }
$lt = [int]$d['LogonType']
if ($InteractiveLogonTypes -notcontains $lt) { continue }
[PSCustomObject]@{
Time = $evt.TimeCreated
LogonType = $lt
}
}
if ($vHits) {
$newest = $vHits | Sort-Object Time -Descending | Select-Object -First 1
Write-Ok " CONFIRMED: interactive logon at $($newest.Time.ToString('yyyy-MM-dd HH:mm:ss')) (LogonType $($newest.LogonType))"
}
else {
Write-Warn2 " Could not confirm on the machine itself (offline, log rolled, or no remote log access)."
}
Write-Host ""
}
$AllResults += $Summary
}
# =============================================================================
# OUTPUT
# =============================================================================
if ($CsvPath -and $AllResults.Count -gt 0) {
$dir = Split-Path $CsvPath -Parent
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$AllResults | Export-Csv -Path $CsvPath -NoTypeInformation
Write-Ok "Exported $($AllResults.Count) rows to $CsvPath"
}
# Emit objects only on request, so the console report isn't duplicated
if ($PassThru) { $AllResults }Code language: PowerShell (powershell)