If you're running Windows Server VMs with Remote Desktop enabled, disconnected sessions are a quiet resource drain. A user closes their laptop, the VPN drops, or they just walk away — and the session sits there in a disconnected state, holding memory, keeping application locks open, and consuming a CAL licence. Multiply that across a few servers and a handful of users and it adds up fast.

I wrote a PowerShell script to deal with it automatically. It queries quser on one or more servers, finds any disconnected sessions that have been idle for longer than a configurable threshold (default 3 hours), logs them off, and writes a JSON audit entry for each one. Designed to run unattended as SYSTEM via an RMM or scheduled task — no prompts, no interaction required.

The problem

On a virtualised environment where you're careful about VM sizing, a handful of ghost sessions sitting overnight is genuinely wasteful. Memory that should be available to the host is locked inside sessions nobody is using. Application locks mean users occasionally can't open files that are technically "in use" by a session that's been disconnected for six hours. And if you're on Per User CALs, those licences aren't freed until the session is properly terminated.

The built-in RDS session timeout settings help, but they're per-server GPO and don't always play nicely with environments where users legitimately need longer sessions. A script gives you more control — you can set the threshold per environment, target specific servers, and keep a log of what got cleaned up and when.

How it works

The script runs quser against each target server and parses the output. The tricky part is that quser produces fixed-width text, and disconnected sessions are missing the SESSIONNAME column — which throws off a naive split. The parser handles this by checking the column count and padding accordingly.

Idle time is normalised via a helper function — quser can return idle time in several formats (., none, 45, 2:15, 1+02:30) and they all need to map to a real TimeSpan for comparison. Any session in Disc state with an idle span over the threshold gets logged off via the built-in logoff command, and the event gets appended to a JSON log file.

The script

#.SYNOPSIS
#    Logs off disconnected RDS sessions idle longer than a threshold
#    and appends each event to a JSON log file.

param(
    [string[]]$ComputerName = $env:COMPUTERNAME,
    [int]$MinIdleHours      = 3,
    [string]$LogPath        = 'C:\Auto Sign off\log.json',
    [switch]$WhatIf
)

function ConvertTo-IdleTimeSpan {
    param([string]$Idle)
    if ([string]::IsNullOrWhiteSpace($Idle)) { return [TimeSpan]::Zero }
    $Idle = $Idle.Trim()
    if ($Idle -eq '.' -or $Idle -ieq 'none') { return [TimeSpan]::Zero }
    if ($Idle -match '^(\d+)\+(\d{1,2}):(\d{2})$') {
        return [TimeSpan]::new([int]$matches[1],[int]$matches[2],[int]$matches[3],0)
    }
    if ($Idle -match '^(\d{1,2}):(\d{2})$') {
        return [TimeSpan]::new([int]$matches[1],[int]$matches[2],0)
    }
    if ($Idle -match '^(\d+)$') { return [TimeSpan]::FromMinutes([int]$matches[1]) }
    return [TimeSpan]::Zero
}

function Write-SignOffLog {
    param([string]$UserName,[string]$Server,[string]$SessionID,[string]$IdleTime,[string]$Path)
    $entry = [PSCustomObject]@{
        username  = $UserName
        text1     = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz')
        server    = $Server
        sessionId = $SessionID
        idleTime  = $IdleTime
        action    = 'logoff'
    }
    $json = $entry | ConvertTo-Json -Compress -Depth 3
    try {
        $dir = Split-Path -Path $Path -Parent
        if ($dir -and -not (Test-Path -LiteralPath $dir)) {
            New-Item -ItemType Directory -Path $dir -Force | Out-Null
        }
        Add-Content -LiteralPath $Path -Value $json -Encoding utf8
    }
    catch { Write-Warning "Failed to write log entry: $_" }
}

function Get-DisconnectedSessions {
    param([string]$Server)
    try {
        $raw = quser /server:$Server 2>$null
        if (-not $raw) { return @() }
        $raw | Select-Object -Skip 1 | ForEach-Object {
            $line  = $_.Trim() -replace '\s{2,}', ','
            $parts = $line -split ','
            if ($parts.Count -eq 5) {
                [PSCustomObject]@{ Server=$Server; UserName=$parts[0].TrimStart('>');
                    SessionName=''; SessionID=$parts[1]; State=$parts[2];
                    IdleTime=$parts[3]; LogonTime=$parts[4] }
            } elseif ($parts.Count -ge 6) {
                [PSCustomObject]@{ Server=$Server; UserName=$parts[0].TrimStart('>');
                    SessionName=$parts[1]; SessionID=$parts[2]; State=$parts[3];
                    IdleTime=$parts[4]; LogonTime=$parts[5] }
            }
        } | Where-Object { $_.State -eq 'Disc' } |
            ForEach-Object {
                $_ | Add-Member -NotePropertyName IdleSpan `
                                -NotePropertyValue (ConvertTo-IdleTimeSpan $_.IdleTime) `
                                -PassThru
            }
    }
    catch { Write-Warning "Could not query sessions on $Server : $_" }
}

$threshold = [TimeSpan]::FromHours($MinIdleHours)

foreach ($server in $ComputerName) {
    Write-Host "`n=== $server (threshold: $MinIdleHours hr) ===" -ForegroundColor Cyan
    $disconnected = Get-DisconnectedSessions -Server $server
    if (-not $disconnected) { Write-Host "No disconnected sessions." -ForegroundColor Green; continue }

    $eligible = $disconnected | Where-Object { $_.IdleSpan -ge $threshold }
    $skipped  = $disconnected | Where-Object { $_.IdleSpan -lt $threshold }

    if ($skipped)  { Write-Host "Skipping $($skipped.Count) under threshold:" -ForegroundColor DarkYellow
                     $skipped | Format-Table UserName,SessionID,IdleTime -AutoSize }
    if (-not $eligible) { Write-Host "None exceed threshold." -ForegroundColor Green; continue }

    Write-Host "$($eligible.Count) eligible for sign-off:" -ForegroundColor Yellow
    $eligible | Format-Table UserName,SessionID,IdleTime -AutoSize

    foreach ($session in $eligible) {
        $target = "$($session.UserName) (ID $($session.SessionID)) on $server"
        if ($WhatIf) { Write-Host "WhatIf: would log off $target" -ForegroundColor Magenta; continue }
        try {
            logoff $session.SessionID /server:$server
            Write-Host "Logged off $target" -ForegroundColor Green
            Write-SignOffLog -UserName $session.UserName -Server $server `
                             -SessionID $session.SessionID -IdleTime $session.IdleTime -Path $LogPath
        }
        catch { Write-Warning "Failed to log off $target : $_" }
    }
}

exit 0

Deployment

The script is designed to run as SYSTEM with no interaction. Drop it on the server and schedule it via Task Scheduler or push it through your RMM hourly. With the default 3-hour threshold, the worst case is a stale session lasting about 4 hours.

Before the first real run, test it with -WhatIf — it'll list eligible sessions without actually logging anyone off:

.\Auto-Sign-Off.ps1 -WhatIf

You can also target a remote server or adjust the threshold:

# Remote server, 4-hour threshold
.\Auto-Sign-Off.ps1 -ComputerName SRV-RDS01 -MinIdleHours 4

Log output

Each sign-off appends one line to the log file in NDJSON format — one JSON object per line, easy to ingest into any log analytics platform:

{"username":"jsmith","text1":"2026-07-19T09:45:12+10:00","server":"SRV-RDS01","sessionId":"6","idleTime":"1+00:18","action":"logoff"}

The script is available on GitHub: lukebevan01/PowerShell-Scripts