Shore Up
An office cleaner pulling dusty, cobweb-covered name folders out of a row of lockers marked with old dates and dropping them in a bin, while the freshly-labelled lockers next to them are left untouched.
Windows

Automatically Remove Stale User Profiles on an RDS Host

Ketan Aagja8 min read
No ratings yet

On a busy Remote Desktop Session Host, local profiles pile up fast — every user who ever logged in leaves a folder under C:\Users, and a system drive fills quietly until logons start failing. This guide sets up an unattended, standard-supported cleanup of profiles that haven't been used in N days.

I assume Windows Server 2019 or 2022 running the RDS Session Host role, with Windows PowerShell 5.1 (the in-box version). I assume local, roaming, or mandatory profiles stored under C:\Users — not FSLogix profile containers or User Profile Disks. If you use FSLogix or UPD, stop here: those are managed by their own mechanisms and this method does not apply.

Before you run this

What it does: it enumerates local user profiles via the Win32_UserProfile class, finds those whose LastUseTime is older than a threshold you set, and deletes them properly — folder and the associated registry entry under ProfileList. Deleting the profile folder by hand leaves orphaned registry keys behind; the CIM delete method removes both, which is why we use it.

Privileges: this must run in an elevated PowerShell session (Run as administrator), and the scheduled task must run as SYSTEM or a local administrator. Deleting profiles touches C:\Users and HKLM.

This is destructive and irreversible. Deleting a profile removes that user's local documents, desktop, cached data, and per-user registry hive (NTUSER.DAT). There is no built-in undo. If the profile is roaming, the server copy of unsynced data is gone. Treat this with the caution you'd give any bulk delete.

Test first. Read the script. Run it on a lab RDS host or a spare VM before it ever touches a production server, and on the real box run it in preview mode (-WhatIf) first and review the list of profiles it would delete. LastUseTime is a real property but it is not a perfect record of "last interactive logon" in every scenario — verify the candidates look right before you commit.

A cleanup run is also a good moment to confirm you have a current backup or a VSS snapshot / VM checkpoint of the system drive, since that is your only recovery path if you delete something you shouldn't have.

The safe approach: the built-in filters

Win32_UserProfile gives us three properties that keep us out of trouble:

  • Special — $true for system accounts (LocalSystem, NetworkService, and so on). Never delete these.
  • Loaded — $true when the profile is currently in use. We skip loaded profiles so we never yank a profile from an active session.
  • LastUseTime — a DateTime (when read via Get-CimInstance; the older Get-WmiObject returns a string that needs conversion, which is one reason we use CIM).

The script

Save this as C:\Scripts\Remove-StaleProfiles.ps1. Change the default -Days and -LogFile to suit you. It is safe by default: because it uses SupportsShouldProcess, running it with -WhatIf previews and deletes nothing.

[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [int]$Days    = 30,                              # profiles idle longer than this are candidates
    [string]$LogFile = 'C:\Scripts\ProfileCleanup.log'
)

$cutoff = (Get-Date).AddDays(-$Days)
"{0}  Run start. Cutoff = {1} (Days = {2})" -f (Get-Date), $cutoff, $Days |
    Tee-Object -FilePath $LogFile -Append

# Select only non-system, not-currently-loaded profiles older than the cutoff.
$stale = Get-CimInstance -ClassName Win32_UserProfile | Where-Object {
    -not $_.Special -and                            # skip system/service profiles
    -not $_.Loaded  -and                            # skip profiles in active use
    $null -ne $_.LastUseTime -and                   # skip profiles with no usable timestamp
    $_.LastUseTime -lt $cutoff
}

foreach ($p in $stale) {
    if ($PSCmdlet.ShouldProcess($p.LocalPath, "Delete profile (last used $($p.LastUseTime))")) {
        try {
            Remove-CimInstance -InputObject $p -ErrorAction Stop   # removes folder AND ProfileList entry
            "{0}  DELETED  {1}  (lastuse {2})" -f (Get-Date), $p.LocalPath, $p.LastUseTime |
                Tee-Object -FilePath $LogFile -Append
        }
        catch {
            "{0}  ERROR    {1}  {2}" -f (Get-Date), $p.LocalPath, $_.Exception.Message |
                Tee-Object -FilePath $LogFile -Append
        }
    }
}

"{0}  Run complete." -f (Get-Date) | Tee-Object -FilePath $LogFile -Append

Replace C:\Scripts\... with wherever you keep scripts and logs. -Days 30 is a placeholder threshold — pick a number that matches how long a user can be away before you're comfortable wiping their local profile.

Preview, then commit

From an elevated prompt, always preview first:

# Shows exactly which profiles WOULD be deleted, changes nothing
C:\Scripts\Remove-StaleProfiles.ps1 -Days 30 -WhatIf

Read that list. When you're satisfied, run it for real:

C:\Scripts\Remove-StaleProfiles.ps1 -Days 30

Scheduling it

Once you trust the script, run it on a schedule as SYSTEM. These ScheduledTasks module cmdlets are standard on Server 2019/2022. Run this once, elevated:

$action  = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Remove-StaleProfiles.ps1" -Days 30'

$trigger = New-ScheduledTaskTrigger -Daily -At '03:00'          # off-hours, no active sessions

$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' `
    -LogonType ServiceAccount -RunLevel Highest

Register-ScheduledTask -TaskName 'Remove Stale RDS Profiles' `
    -Action $action -Trigger $trigger -Principal $principal `
    -Description 'Deletes local user profiles idle longer than 30 days'

Note there is no -WhatIf in the scheduled argument — the task performs real deletions. That's the point of previewing manually first. Schedule it for a maintenance window when no one is logged on, so Loaded profiles (which we skip anyway) are minimal.

The native alternative

Microsoft ships a Group Policy setting that does much the same thing without a script:

Computer Configuration → Policies → Administrative Templates → System → User Profiles → "Delete user profiles older than a specified number of days on system restart."

It runs at restart rather than on a schedule and uses its own "last used" determination, which can differ slightly from LastUseTime. If you already reboot session hosts regularly and don't need logging or dry-run control, this is the lower-maintenance option. The PowerShell approach wins when you want a log, a preview, and cleanup without a reboot.

Verify it worked

Check the log first:

Get-Content C:\Scripts\ProfileCleanup.log -Tail 40

Then confirm the profiles are actually gone — both from CIM and from disk:

# Remaining non-system profiles and their last-use dates
Get-CimInstance Win32_UserProfile |
    Where-Object { -not $_.Special } |
    Select-Object LocalPath, LastUseTime, Loaded |
    Sort-Object LastUseTime

# The folders that remain
Get-ChildItem C:\Users

Because we used the CIM delete method, the matching entries under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList are removed too — you can spot-check that key in regedit if you want to be sure no orphans remain.

Undo

There is no rollback for a deleted profile. If you deleted one you needed, your options are recovery from your backup, from a VSS previous version of the folder if shadow copies are enabled, or from a VM checkpoint taken before the run. This is exactly why the preview step and a current backup are non-negotiable.

For exact property definitions and method behaviour, see the Microsoft Learn documentation for the Win32_UserProfile class, the Remove-CimInstance cmdlet, and Register-ScheduledTask before adapting any of this to your environment.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.

Daily Report of Off-Hours Logon Failures Across the Domain

This guide builds a scheduled PowerShell job that reads the Security event log on every domain controller, pulls failed-logon events (Event ID 4625 ) from the previous day, keeps only the ones that happened outside your defined business hours (and optionally on weekends), and writes them to a dated CSV. Optionally it emails the file. It is a reporting job — it reads logs and writes a report file. It does not change Active Directory, disable accounts, or touch any policy.

10 min read

Find AD Security Groups With Cross-Domain Members for Cleanup

In a multi-domain forest, group membership sprawls across domain boundaries over the years: a universal or domain-local group in corp ends up holding users from emea , or Foreign Security Principals (FSPs) from a trusted external forest that nobody remembers adding. Before you clean any of that up, you need an accurate inventory. This guide gives you a read-only report of every group in one domain that contains members from another domain, plus a carefully gated way to remove the ones you've decided to drop.

9 min read

Automate a Weekly NTFS Share Permission Drift Audit

Share permissions drift. Someone grants a contractor "just temporary" access, a helpdesk ticket adds a group to a folder, an app installer rewrites an ACL — and six months later nobody remembers why Everyone can write to the finance share. This guide builds a simple, read-only baseline of your NTFS share ACLs and a weekly job that tells you exactly what has changed since.

10 min read

Detect and Quarantine Suspicious File Extensions in Shared Folders

This guide builds a PowerShell script that scans an SMB shared folder for files whose extensions are commonly used to carry malware ( .exe , .scr , .js , .vbs , .bat , and so on), and moves any it finds into a locked-down quarantine folder outside the share, writing every action to a log. It is a crude, extension-based screen — a tripwire, not an antivirus engine. It does not inspect file contents and will not catch a malicious .docm or a renamed payload. Treat it as one layer, not the layer.

9 min read