<# .SYNOPSIS Removes Windows user profiles that haven't been used in a set number of days, the supported way: through Win32_UserProfile, never by deleting folders. .DESCRIPTION Lists local profiles with CIM, skips special (system) profiles, loaded profiles, the account running the script and anything matching -ExcludeUser, then removes the rest that are older than -Days with Remove-CimInstance. That deletes the folder AND the ProfileList registry entry together, so Windows doesn't hand the user a TEMP profile next time. Age comes from the profile's last unload time where Windows records it, and falls back to LastUseTime. Supports -WhatIf; always run that first. .PARAMETER Days Profiles unused for longer than this are removed. Default: 90. .PARAMETER ExcludeUser Account names to never touch. Wildcards work. Matched against DOMAIN\user and user. .EXAMPLE .\Remove-StaleUserProfile.ps1 -Days 60 -WhatIf .EXAMPLE .\Remove-StaleUserProfile.ps1 -Days 120 -ExcludeUser 'CONTOSO\svc-*', 'labadmin' -Confirm:$false #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( [ValidateRange(14, 3650)] [int]$Days = 90, [string[]]$ExcludeUser = @('Administrator', 'defaultuser*') ) $cutoff = (Get-Date).AddDays(-$Days) $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $profileList = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' function Get-LastUsed { param($UserProfile) # Newer Windows builds record when the profile was last unloaded (logoff). It's a much # better signal than LastUseTime, which all sorts of things can bump. $reg = Get-ItemProperty -LiteralPath (Join-Path $profileList $UserProfile.SID) -ErrorAction SilentlyContinue if ($reg -and $null -ne $reg.LocalProfileUnloadTimeHigh -and $null -ne $reg.LocalProfileUnloadTimeLow) { # Registry DWORDs come back as signed Int32, so reinterpret the bits before combining. $high = [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$reg.LocalProfileUnloadTimeHigh), 0) $low = [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$reg.LocalProfileUnloadTimeLow), 0) $ft = ([uint64]$high -shl 32) -bor [uint64]$low if ($ft -gt 0) { return [pscustomobject]@{ When = [datetime]::FromFileTime([int64]$ft); Source = 'UnloadTime' } } } if ($UserProfile.LastUseTime) { return [pscustomobject]@{ When = $UserProfile.LastUseTime; Source = 'LastUseTime' } } [pscustomobject]@{ When = $null; Source = 'Unknown' } } $profiles = Get-CimInstance -ClassName Win32_UserProfile -Filter 'Special = FALSE AND Loaded = FALSE' foreach ($p in $profiles) { if ($p.SID -eq $me) { continue } try { $account = ([System.Security.Principal.SecurityIdentifier]$p.SID).Translate([System.Security.Principal.NTAccount]).Value } catch { # The account was deleted from AD, or this machine can't reach a DC. Use the folder name. $account = Split-Path $p.LocalPath -Leaf } $shortName = $account.Split('\')[-1] if ($ExcludeUser | Where-Object { $account -like $_ -or $shortName -like $_ }) { Write-Verbose "Excluded: $account" continue } $used = Get-LastUsed -UserProfile $p if (-not $used.When -or $used.When -ge $cutoff) { Write-Verbose ("Keeping {0} (last used {1}, {2})" -f $account, $used.When, $used.Source) continue } $row = [pscustomobject]@{ Account = $account LocalPath = $p.LocalPath LastUsed = $used.When AgeSource = $used.Source DaysIdle = [int]((Get-Date) - $used.When).TotalDays Action = $null } if ($PSCmdlet.ShouldProcess("$account ($($p.LocalPath)), idle $($row.DaysIdle) days", 'Remove user profile')) { try { Remove-CimInstance -InputObject $p -Confirm:$false -ErrorAction Stop $row.Action = 'Removed' } catch { $row.Action = "Failed: $($_.Exception.Message)" } } else { $row.Action = if ($WhatIfPreference) { 'WouldRemove' } else { 'Skipped' } } $row }