How to Generate a User Accounts Audit Report in Batch Script
Managing user accounts is a core security responsibility. Over time, dormant accounts, created for temporary contractors, former employees, or testing purposes, can accumulate, providing a significant security risk if they aren't disabled or deleted. A Batch script can audit every local user account, reporting their enabled/disabled status, last logon date, password expiration settings, and group membership. This structured report allows you to identify vulnerable accounts that need to be secured or removed.
This guide will explain how to generate a comprehensive user account audit using PowerShell from a Batch script.
Method 1: Comprehensive User Account Audit
This method generates a complete audit report covering all local accounts, their security properties, and administrative group membership.
Implementation
@echo off
setlocal
for /f "delims=" %%t in (
'powershell -NoProfile -Command "Get-Date -Format ''yyyyMMdd_HHmmss''"'
) do set "Stamp=%%t"
set "ReportFile=%~dp0UserAudit_%COMPUTERNAME%_%Stamp%.txt"
:: Verify admin privileges
net session >nul 2>&1
if errorlevel 1 (
echo [ERROR] User account auditing requires administrator privileges. >&2
echo Right-click and select "Run as administrator." >&2
endlocal
exit /b 1
)
echo [INFO] Generating user account audit report...
:: =============================================
:: Report Header
:: =============================================
(
echo ==================================================
echo USER ACCOUNT SECURITY AUDIT REPORT
echo ==================================================
) > "%ReportFile%"
for /f "delims=" %%t in (
'powershell -NoProfile -Command "Get-Date -Format ''yyyy-MM-dd HH:mm:ss''"'
) do echo Generated: %%t >> "%ReportFile%"
echo Computer: %COMPUTERNAME% >> "%ReportFile%"
echo Audited By: %USERNAME% >> "%ReportFile%"
echo. >> "%ReportFile%"
:: =============================================
:: Section 1: Account Summary
:: =============================================
echo [1/4] Collecting account information...
echo --- [1] LOCAL USER ACCOUNTS --- >> "%ReportFile%"
echo. >> "%ReportFile%"
powershell -NoProfile -Command ^
"Get-LocalUser | ForEach-Object {" ^
" $lastLogon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'Never' };" ^
" $pwdSet = if ($_.PasswordLastSet) { $_.PasswordLastSet.ToString('yyyy-MM-dd') } else { 'Never' };" ^
" $daysSinceLogon = if ($_.LastLogon) { ((Get-Date) - $_.LastLogon).Days } else { 'N/A' };" ^
" [PSCustomObject]@{" ^
" Name = $_.Name;" ^
" Enabled = $_.Enabled;" ^
" LastLogon = $lastLogon;" ^
" 'Days Idle' = $daysSinceLogon;" ^
" 'Password Set' = $pwdSet;" ^
" 'Pwd Expires' = -not $_.PasswordNeverExpires;" ^
" Source = if ($_.PrincipalSource) { $_.PrincipalSource } else { 'Local' }" ^
" }" ^
"} | Format-Table -AutoSize" >> "%ReportFile%"
echo. >> "%ReportFile%"
:: =============================================
:: Section 2: Security Concerns
:: =============================================
echo [2/4] Identifying security concerns...
echo --- [2] SECURITY CONCERNS --- >> "%ReportFile%"
echo. >> "%ReportFile%"
powershell -NoProfile -Command ^
"$users = Get-LocalUser;" ^
"$concerns = @();" ^
"foreach ($u in $users) {" ^
" $issues = @();" ^
" if ($u.Enabled -and $u.PasswordNeverExpires) {" ^
" $issues += 'Password never expires'" ^
" };" ^
" if ($u.Enabled -and -not $u.LastLogon) {" ^
" $issues += 'Never logged on (orphan account?)'" ^
" };" ^
" if ($u.Enabled -and $u.LastLogon -and ((Get-Date) - $u.LastLogon).Days -gt 90) {" ^
" $issues += \"Inactive for $([math]::Floor(((Get-Date) - $u.LastLogon).Days)) days\"" ^
" };" ^
" if ($u.Enabled -and -not $u.PasswordRequired) {" ^
" $issues += 'No password required'" ^
" };" ^
" if ($issues.Count -gt 0) {" ^
" $concerns += [PSCustomObject]@{" ^
" Account = $u.Name;" ^
" Issues = $issues -join '; '" ^
" }" ^
" }" ^
"};" ^
"if ($concerns) {" ^
" Write-Output \" Found $($concerns.Count) account(s) with security concerns:\";" ^
" Write-Output '';" ^
" $concerns | ForEach-Object { Write-Output \" $($_.Account): $($_.Issues)\" }" ^
"} else {" ^
" Write-Output ' No security concerns found.'" ^
"}" >> "%ReportFile%"
echo. >> "%ReportFile%"
:: =============================================
:: Section 3: Administrators Group
:: =============================================
echo [3/4] Auditing administrator group...
echo --- [3] ADMINISTRATOR GROUP MEMBERSHIP --- >> "%ReportFile%"
echo. >> "%ReportFile%"
:: Use SID to find the Administrators group (works on all languages)
powershell -NoProfile -Command ^
"$adminGroup = Get-LocalGroup | Where-Object { $_.SID.Value -eq 'S-1-5-32-544' };" ^
"if ($adminGroup) {" ^
" Write-Output \" Group Name: $($adminGroup.Name)\";" ^
" Write-Output '';" ^
" $members = Get-LocalGroupMember -Group $adminGroup.Name -ErrorAction SilentlyContinue;" ^
" if ($members) {" ^
" $members | ForEach-Object {" ^
" Write-Output \" $($_.ObjectClass): $($_.Name)\"" ^
" };" ^
" Write-Output '';" ^
" Write-Output \" Total admin members: $($members.Count)\"" ^
" } else {" ^
" Write-Output ' No members found.'" ^
" }" ^
"}" >> "%ReportFile%"
echo. >> "%ReportFile%"
:: =============================================
:: Section 4: Password Policy
:: =============================================
echo [4/4] Checking password policy...
echo --- [4] LOCAL PASSWORD POLICY --- >> "%ReportFile%"
echo. >> "%ReportFile%"
net accounts >> "%ReportFile%" 2>&1
echo. >> "%ReportFile%"
echo ================================================== >> "%ReportFile%"
echo [OK] Audit report saved to: %ReportFile%
endlocal
exit /b 0
Sample report output:
==================================================
USER ACCOUNT SECURITY AUDIT REPORT
==================================================
Generated: 2024-05-10 14:32:05
Computer: WORKSTATION-07
Audited By: admin
--- [1] LOCAL USER ACCOUNTS ---
Name Enabled LastLogon Days Idle Password Set Pwd Expires Source
---- ------- --------- --------- ------------ ----------- ------
Administrator True 2024-05-10 09:15 0 2024-01-15 False Local
DefaultAccount False Never N/A Never False Local
Guest False Never N/A Never False Local
jsmith True 2024-05-08 17:30 2 2024-03-20 True Local
temp_contractor True 2023-08-15 11:22 268 2023-08-01 False Local
test_user True Never N/A 2024-02-10 True Local
--- [2] SECURITY CONCERNS ---
Found 3 account(s) with security concerns:
Administrator: Password never expires
temp_contractor: Inactive for 268 days; Password never expires
test_user: Never logged on (orphan account?)
--- [3] ADMINISTRATOR GROUP MEMBERSHIP ---
Group Name: Administrators
User: WORKSTATION-07\Administrator
User: WORKSTATION-07\jsmith
Total admin members: 2
--- [4] LOCAL PASSWORD POLICY ---
(net accounts output)
==================================================
Why PowerShell instead of net user and wmic:
net usershows a list of account names but no metadata (no enabled status, no last logon, no password expiration).wmic useraccountis deprecated and returns\r-corrupted output. It also cannot showLastLogon(which is not a WMI property for local accounts).Get-LocalUserreturns all relevant properties as typed objects:Enabled,LastLogon,PasswordLastSet,PasswordNeverExpires,PasswordRequired.
Why the Administrators group is queried by SID:
The Administrators group is named differently in localized Windows installations: "Administratoren" (German), "Administrateurs" (French), "Amministratori" (Italian). Using the well-known SID S-1-5-32-544 finds the correct group regardless of OS language.
Method 2: Inactive Account Detection
For focused security review, this method identifies accounts that haven't been used within a configurable number of days, the primary candidates for disabling or deletion.
@echo off
setlocal
set "InactiveDays=90"
net session >nul 2>&1
if errorlevel 1 (
echo [ERROR] Administrator privileges required. >&2
endlocal
exit /b 1
)
echo [INFO] Finding accounts inactive for more than %InactiveDays% days...
echo --------------------------------------------------
powershell -NoProfile -Command ^
"$cutoff = (Get-Date).AddDays(-%InactiveDays%);" ^
"$inactive = Get-LocalUser | Where-Object {" ^
" $_.Enabled -eq $true -and (" ^
" ($_.LastLogon -and $_.LastLogon -lt $cutoff) -or" ^
" (-not $_.LastLogon)" ^
" )" ^
"};" ^
"if (-not $inactive) {" ^
" Write-Host 'No inactive enabled accounts found.';" ^
" exit 0" ^
"};" ^
"Write-Host \"Found $($inactive.Count) inactive enabled account(s):`n\";" ^
"$inactive | ForEach-Object {" ^
" $lastLogon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd') } else { 'Never' };" ^
" $idle = if ($_.LastLogon) { ((Get-Date) - $_.LastLogon).Days } else { 'N/A' };" ^
" [PSCustomObject]@{" ^
" Account = $_.Name;" ^
" LastLogon = $lastLogon;" ^
" 'Days Idle' = $idle;" ^
" 'Pwd Never Expires' = $_.PasswordNeverExpires;" ^
" Description = $_.Description" ^
" }" ^
"} | Format-Table -AutoSize -Wrap;" ^
"Write-Host '';" ^
"Write-Host '[ACTION] Review these accounts and disable any that are no longer needed:';" ^
"Write-Host ' Disable-LocalUser -Name \"account_name\"'"
echo --------------------------------------------------
endlocal
exit /b 0
What to do with inactive accounts:
- Verify with the account owner or their manager that the account is no longer needed.
- Disable the account first (
Disable-LocalUser -Name "username") rather than deleting it. This preserves the account's SID and file ownership for auditing. - Delete only after a waiting period (e.g., 30 days after disabling) if no one claims the account.
- Document the action in your change management system.
Method 3: Fleet-Wide Account Summary CSV
For auditing accounts across many machines, export a summary line per user per machine to a shared CSV.
@echo off
setlocal
set "CSVFile=\\Server\Audit\user_accounts.csv"
if not exist "%CSVFile%" (
echo "Timestamp","Computer","Username","Enabled","LastLogon","DaysIdle","PwdNeverExpires","IsAdmin" > "%CSVFile%" 2>nul
)
powershell -NoProfile -Command ^
"$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss';" ^
"$adminSID = 'S-1-5-32-544';" ^
"$adminGroup = Get-LocalGroup | Where-Object { $_.SID.Value -eq $adminSID };" ^
"$adminMembers = @();" ^
"if ($adminGroup) {" ^
" $adminMembers = (Get-LocalGroupMember -Group $adminGroup.Name -ErrorAction SilentlyContinue).Name" ^
"};" ^
"Get-LocalUser | ForEach-Object {" ^
" $lastLogon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'Never' };" ^
" $idle = if ($_.LastLogon) { ((Get-Date) - $_.LastLogon).Days } else { -1 };" ^
" $isAdmin = $adminMembers -contains \"$env:COMPUTERNAME\\$($_.Name)\";" ^
" Write-Output ('\"' + $env:COMPUTERNAME + '\",\"' + $ts + '\",\"' + $_.Name + '\",\"' + $_.Enabled + '\",\"' + $lastLogon + '\",\"' + $idle + '\",\"' + $_.PasswordNeverExpires + '\",\"' + $isAdmin + '\"')" ^
"}" >> "%CSVFile%" 2>nul
echo [OK] User account data exported for %COMPUTERNAME%.
endlocal
exit /b 0
What to look for in the fleet CSV:
- Enabled accounts with
DaysIdle > 90: Candidates for disabling. - Accounts where
PwdNeverExpires = TrueANDIsAdmin = True: High-priority security risk, i.e. an admin account with a password that never expires. - Same username appearing on many machines with admin rights: May indicate an account created by a deployment script that was never cleaned up.
- Accounts with
LastLogon = NeverANDEnabled = True: Orphan accounts that were created but never used.
How to Avoid Common Errors
Wrong Way: Using net user for Account Details
:: Only shows a list of names, no enabled status, no last logon, no password info
net user
net user lists account names in a multi-column format but provides no metadata. To get details, you must run net user <name> for each account individually and parse the unstructured text output, fragile and locale-dependent.
Correct Way: Use Get-LocalUser, which returns all properties as typed objects in a single query.
Wrong Way: Using wmic useraccount for Local Account Auditing
wmic useraccount is deprecated, produces \r-corrupted output, and crucially does NOT include LastLogon for local accounts (this property is only available through Get-LocalUser or Active Directory queries for domain accounts).
Correct Way: Use Get-LocalUser for local accounts. For domain accounts, use Get-ADUser (requires the ActiveDirectory PowerShell module).
Problem: Hardcoded Group Name "Administrators"
:: BREAKS on non-English Windows
net localgroup Administrators
The Administrators group is named differently in localized Windows: "Administratoren" (German), "Administrateurs" (French), etc.
Solution: Find the group by its well-known SID (S-1-5-32-544), which is identical on every Windows installation regardless of language:
$adminGroup = Get-LocalGroup | Where-Object { $_.SID.Value -eq 'S-1-5-32-544' }
Problem: net user /domain on Non-Domain Machines
Running net user /domain on a workgroup machine (not joined to Active Directory) produces an error.
Solution: Check domain membership before attempting domain queries:
systeminfo | findstr /i "Domain" | findstr /v /i "Workgroup"
if errorlevel 1 echo This machine is not domain-joined.
Best Practices and Rules
1. Focus on Enabled Accounts with Admin Rights
The most dangerous accounts are those that are enabled, have administrative privileges, and have passwords that never expire. Prioritize these in every audit.
2. Disable Before Deleting
When removing accounts, disable them first and wait 30 days. This preserves the account SID, file ownership, and audit trail. Immediate deletion makes forensic investigation difficult if the account was compromised.
3. Check Password Policy Alongside Accounts
An account audit without a password policy review is incomplete. Include net accounts output (as in Method 1, Section 4) to verify minimum password length, lockout thresholds, and maximum password age.
4. Use SID-Based Group Lookups
Never hardcode group names like "Administrators" or "Users." Use SIDs for cross-language compatibility: S-1-5-32-544 (Administrators), S-1-5-32-545 (Users), S-1-5-32-555 (Remote Desktop Users).
5. Schedule Regular Audits
Run Method 3 as a scheduled task monthly. User accounts accumulate over time, contractors leave, test accounts are forgotten, service accounts become orphaned. Regular auditing catches these before they become security incidents.
6. Distinguish Local from Domain Accounts
On domain-joined machines, Get-LocalUser shows only local accounts. Domain accounts require Get-ADUser (from the ActiveDirectory module) or net user /domain. Ensure your audit covers both if the machine is domain-joined.
Conclusions
Generating a user accounts audit report transforms account management from a manual, error-prone task into a systematic security practice. By using Get-LocalUser for comprehensive account data, SID-based group lookups for cross-language reliability, and automated security concern detection, you gain clear visibility into account hygiene across your infrastructure. This proactive approach ensures that dormant accounts are identified, privileged access is minimized, and password policies are enforced, the foundation of secure Windows administration.