Find Which Computer a Domain User Last Logged On From

Active Directory stores when a user last logged on, but not where. This script fills that gap by querying the Security event log across every domain controller, correlating Kerberos and NTLM authentication events, and resolving them to a computer name. Run it when a user reports a problem and you need to know what machine they were on, or when you're chasing down suspicious activity and need a starting point.

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 ActiveDirectory will 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)