# Version date: 2026-08-13 10:57 (CEST) # ------------------------------------------------------------ # Finds local user accounts that have been inactive since before a # cutoff date you provide, so they can be reviewed one by one and # permanently deleted - account AND profile folder in C:\Users. # # Candidates are every account that is not a member of the local # Administrators group and last logged on before the cutoff date (or # never logged on at all). # # Deleting an account removes BOTH the account AND its entire Windows # profile (C:\Users\). # # THIS CANNOT BE UNDONE. A CSV record of what was actually deleted is # kept (DeletedUsers.csv). # ------------------------------------------------------------ # Verify this is running with Administrator rights; stop with a clear # message if not, since deleting accounts and profiles requires elevation. if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host "ERROR: Run this script as Administrator." ; exit } # By default, the record of deleted accounts is written to the same # folder this script is saved in - PowerShell's built-in $PSScriptRoot # variable always points there automatically. To use a different, fixed # folder instead, replace the line below with a fixed path. $CsvFolder = $PSScriptRoot $DeletedUsersFile = Join-Path $CsvFolder "DeletedUsers.csv" # Accounts never shown or touched by this script, in addition to whoever # is already excluded for having admin rights (see below). Add more # names here as needed, separated by commas. Matching is case-insensitive. $ExcludedUsers = @( "Administrator", "Guest", "Gast", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0" ) Write-Host "" Write-Host "This script deletes user accounts that have been inactive since" Write-Host "before a cutoff date you provide, together with their folders in" Write-Host "C:\Users and the associated registry entry. IT DOES NOT DELETE" Write-Host "ACCOUNTS OR data IN OTHER LOCATIONS, SUCH AS E:\data OR SIMILAR -" Write-Host "please delete those manually if needed." Write-Host "" # Resolve the localized name of the built-in Administrators group # (language-independent, via well-known SID, so this also works on # non-English Windows), and get the names of everyone in it - these # accounts are never shown as delete candidates. The same group name is # reused further down to unlock profile folders before deleting them. $AdminGroupName = ([System.Security.Principal.SecurityIdentifier]"S-1-5-32-544").Translate([System.Security.Principal.NTAccount]).Value.Split("\")[1] $AdminUsers = @( Get-LocalGroupMember -Group $AdminGroupName -ErrorAction Stop | ForEach-Object { $_.Name.Split("\")[-1] } ) # -------------------------------------------------------------------------- # Ask for a cutoff date, retrying until a valid one is entered or the # user aborts. # -------------------------------------------------------------------------- $CutoffDate = $null while (-not $CutoffDate) { $DateInput = Read-Host "To see a table with all concerned users, enter a cut-off date (format: yyyy-mm-dd, e.g. 2030-12-31). (a) to abort" if ($DateInput -ieq "a") { Write-Host "Aborted by user." exit } $ParsedDate = [DateTime]::MinValue if ([DateTime]::TryParseExact($DateInput, "yyyy-MM-dd", [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$ParsedDate)) { $CutoffDate = $ParsedDate } else { Write-Host "Not a valid date in the format yyyy-mm-dd. Please try again." } } # -------------------------------------------------------------------------- # Build the candidate list: every local, non-admin account that last # logged on before the cutoff date, or never logged on at all. # -------------------------------------------------------------------------- $CandidateUsers = Get-LocalUser | Where-Object { $_.Name -notin $ExcludedUsers -and $_.Name -notin $AdminUsers } | ForEach-Object { [PSCustomObject]@{ Name = $_.Name FullName = $_.FullName LastLogon = $_.LastLogon } } | Where-Object { -not $_.LastLogon -or $_.LastLogon -lt $CutoffDate } | Sort-Object @{Expression = { if ($_.LastLogon) { $_.LastLogon } else { [DateTime]::MinValue } }} if ($CandidateUsers.Count -eq 0) { Write-Host "" Write-Host "No accounts inactive since before $($CutoffDate.ToString('yyyy-MM-dd')) were found." exit } # Displays a table with LastLogon shown as yyyy-mm-dd, regardless of the # system's regional date format - the underlying $CandidateUsers objects # keep the real DateTime value throughout, for sorting, filtering, and # the CSV record; only this display copy is reformatted as text. function Show-UserTable { param([array]$Users) $Users | Select-Object Name, FullName, @{ Name = "LastLogon" Expression = { if ($_.LastLogon) { $_.LastLogon.ToString("yyyy-MM-dd") } else { "" } } } | Format-Table -AutoSize | Out-String | Write-Host } Write-Host "" Write-Host "========================================" Write-Host "Accounts inactive since before $($CutoffDate.ToString('yyyy-MM-dd'))" Write-Host "========================================" Show-UserTable $CandidateUsers Write-Host "" Write-Host "WARNING: Deleting accounts here removes them AND their entire profile permanently. This cannot be restored." $ModeChoice = Read-Host "(a)bort, (g)o through users from table one by one to decide whom to delete and whom to keep" if ($ModeChoice -ine "g") { Write-Host "Aborted by user - nothing was deleted." exit } $DeletedCount = 0 $KeptCount = 0 $Errors = 0 foreach ($User in $CandidateUsers) { Write-Host "" Show-UserTable @($User) $ItemChoice = Read-Host "(d)elete user, (k)eep user, (a)bort remaining" if ($ItemChoice -ieq "a") { Write-Host "Stopped by user - remaining accounts left untouched." break } if ($ItemChoice -ine "d") { $KeptCount++ continue } try { # Even running elevated, an Administrator token does not always # guarantee access to a profile folder - the same "You don't have # permission... Continue?" situation Windows Explorer shows, which # Windows' own account-deletion mechanism is not immune to either. # Unlock access proactively before attempting removal. # # Deliberately NOT recursive (no /R on takeown, no /T on icacls): # every Windows profile contains legacy backward-compatibility # junctions (e.g. AppData\Local\Application Data, which loops back # on itself) - a recursive tool that doesn't skip reparse points # walks into these and recurses effectively forever, flooding the # console with "path not found" errors once the constructed path # gets too long. Unlocking just the top-level profile folder is # sufficient here: Remove-CimInstance below is the OS's own proper # profile-removal mechanism and doesn't need every nested file # pre-unlocked to work. $ProfilePath = Join-Path "C:\Users" $User.Name if (Test-Path $ProfilePath) { takeown /F $ProfilePath | Out-Null icacls $ProfilePath /grant "${AdminGroupName}:(OI)(CI)F" /C | Out-Null } # Remove the Windows profile properly (folder + registry entry), # not just the folder, using the same mechanism Windows itself # uses. If the account was never logged into, there may be no # profile to remove at all - that's fine, just skip that part. $ProfileToRemove = Get-CimInstance Win32_UserProfile -Filter "LocalPath='C:\\Users\\$($User.Name)'" -ErrorAction SilentlyContinue if ($ProfileToRemove) { Remove-CimInstance -InputObject $ProfileToRemove -ErrorAction Stop } Remove-LocalUser -Name $User.Name -ErrorAction Stop if ($User.FullName) { Write-Host "Deleted: $($User.Name) ($($User.FullName))" } else { Write-Host "Deleted: $($User.Name)" } Write-Host "Now would be a good time to delete this user's image data folder." [PSCustomObject]@{ Name = $User.Name LastLogon = $User.LastLogon DeletedAt = (Get-Date -Format "yyyy-MM-dd HH:mm:ss") } | Export-Csv -Path $DeletedUsersFile -NoTypeInformation -Encoding UTF8 -Append $DeletedCount++ } catch { Write-Host "ERROR deleting $($User.Name): $($_.Exception.Message)" $Errors++ } } Write-Host "" Write-Host "========================================" Write-Host "Summary: $DeletedCount account(s) deleted, $KeptCount kept, $Errors error(s)." Write-Host "========================================" if ($DeletedCount -gt 0) { Write-Host "Record written to: $DeletedUsersFile" }