~/2025/10/01/powershell-active-directory-bulk-reset-passwords-with-a-forced-change.md
PowerShell: Active Directory – Bulk Password Reset with Forced Change
--- author: Tom Lasswell date: read: 5 min in: [ps, scripts] tags: [powershell, active-directory] ---
$ grep -n '^#' post.md
A credential exposure, a breached vendor list, or a phishing campaign that landed will eventually put a list of usernames in your hands with the instruction "reset all of these." Doing it by hand in ADUC doesn't scale past a handful of accounts, and it's easy to fat-finger a password into a policy violation or forget to tick "User must change password at next logon." This script takes a CSV of sAMAccountName values, generates a cryptographically random password for each one that is at least as long as the password policy that actually applies to that user, resets it, forces a change at next logon, unlocks the account if it was locked, and writes a report you can hand to whoever notifies the affected users.
It also deals with the one case that trips up most bulk-reset scripts: an account with Password never expires set. Microsoft's Set-ADUser documentation states that PasswordNeverExpires and ChangePasswordAtLogon can't both be true on the same account, so the script either clears the never-expires flag (when you ask it to with -ClearPasswordNeverExpires) or resets the password, skips the forced change, and flags the account in the report so nobody assumes it was handled.
Requirements
- Windows PowerShell 5.1 or PowerShell 7 on Windows, with the ActiveDirectory module (RSAT). The module talks to Active Directory Web Services on a writable DC;
Set-ADAccountPassworddoesn't work against a read-only DC or the global catalog port. - The Reset password right on the target accounts, plus write access to
pwdLastSet(for the forced change),lockoutTime(for the unlock) anduserAccountControl(only if you use-ClearPasswordNeverExpires). Delegated rights on the relevant OUs are enough; Domain Admin is not required. - Read access to the password settings container if fine-grained password policies are in use, so
Get-ADUserResultantPasswordPolicycan return the policy for each user. - A CSV with a
SamAccountNamecolumn.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
CsvPath | String | Yes | Path to the CSV of accounts to reset. Needs a SamAccountName column. |
ReportPath | String | Yes | Path to write the report CSV (username, temporary password, what was done). |
PasswordLength | Int | No | Minimum length of the generated password. Defaults to 16. Raised automatically if a user's effective policy requires more. |
ClearPasswordNeverExpires | Switch | No | Clears Password never expires on accounts that have it so the forced change can be set. Without it those accounts are reset and flagged, but not forced to change. |
The script supports -WhatIf and -Confirm.
Usage
Build the input file. One SamAccountName per row:
SamAccountName
jsmith
mgarcia
kpatel
svc-reporting
Dry run first, to confirm every account resolves and see which ones have never-expires set:
.\Reset-ADAccountPasswordsBulk.ps1 -CsvPath "C:\Security\accounts-to-reset.csv" -ReportPath "C:\Security\reset-report.csv" -WhatIf
Run it for real:
.\Reset-ADAccountPasswordsBulk.ps1 -CsvPath "C:\Security\accounts-to-reset.csv" -ReportPath "C:\Security\reset-report_2025-10-01.csv"
Reset jsmith: ChangePasswordAtLogon set.
WARNING: mgarcia not found in Active Directory - skipped.
Reset kpatel: ChangePasswordAtLogon set, account unlocked.
WARNING: svc-reporting has PasswordNeverExpires set; password reset but ChangePasswordAtLogon NOT set. Re-run with -ClearPasswordNeverExpires or handle it by hand.
3 of 4 account(s) reset, 1 failed or skipped. Report written to C:\Security\reset-report_2025-10-01.csv
The report (passwords replaced with placeholders here):
"SamAccountName","TemporaryPassword","Result","ResetDate"
"jsmith","<generated>","Reset; must change at next logon","2025-10-01 09:12:44"
"mgarcia","","Not found","2025-10-01 09:12:44"
"kpatel","<generated>","Reset; must change at next logon; unlocked","2025-10-01 09:12:45"
"svc-reporting","<generated>","Reset; NOT forced (PasswordNeverExpires)","2025-10-01 09:12:45"
Check that the forced change actually landed (a pwdLastSet of 0 is what "must change at next logon" is stored as, which the module surfaces as an empty PasswordLastSet and PasswordExpired = True):
Import-Csv -Path "C:\Security\accounts-to-reset.csv" | ForEach-Object {
Get-ADUser -Identity $_.SamAccountName -Properties PasswordLastSet, PasswordExpired, PasswordNeverExpires, LockedOut
} | Select-Object -Property SamAccountName, PasswordLastSet, PasswordExpired, PasswordNeverExpires, LockedOut
Script
<#
.SYNOPSIS
Resets passwords for a list of Active Directory accounts to random values and forces a change at next logon.
.DESCRIPTION
Reads a CSV of SamAccountName values. For each account it looks up the effective password policy
(a fine-grained PSO if one applies, otherwise the default domain policy), generates a random password
from a cryptographic RNG that is at least that long and contains all four character classes, applies
it with Set-ADAccountPassword -Reset, sets ChangePasswordAtLogon, and unlocks the account if it was
locked. Accounts with PasswordNeverExpires are reset and flagged, or have the flag cleared first when
-ClearPasswordNeverExpires is used. Writes a report CSV with each username, its temporary password
and the outcome, so it can be handed off securely and deleted after use.
.PARAMETER CsvPath
Path to the CSV of accounts to reset. Needs a SamAccountName column.
.PARAMETER ReportPath
Path to write the report CSV (username, temporary password, what was done).
.PARAMETER PasswordLength
Minimum length of the generated password. Defaults to 16. Raised automatically if a user's
effective policy requires more.
.PARAMETER ClearPasswordNeverExpires
Clears Password never expires on accounts that have it so the forced change can be set.
.EXAMPLE
.\Reset-ADAccountPasswordsBulk.ps1 -CsvPath "C:\Security\accounts-to-reset.csv" -ReportPath "C:\Security\reset-report_2025-10-01.csv"
.NOTES
Author : Thomas Lasswell (https://www.techcolumnist.com)
Version : 1.0 (2025-10-01)
Requires: ActiveDirectory module, Reset Password rights on the target accounts
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory = $true)]
[string]$CsvPath,
[Parameter(Mandatory = $true)]
[string]$ReportPath,
[Parameter(Mandatory = $false)]
[ValidateRange(12, 128)]
[int]$PasswordLength = 16,
[Parameter(Mandatory = $false)]
[switch]$ClearPasswordNeverExpires
)
Import-Module ActiveDirectory -ErrorAction Stop
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
function Get-RandomIndex {
param([int]$MaxExclusive)
# Rejection sampling avoids the modulo bias of a plain "random % length".
$limit = [uint32]::MaxValue - ([uint32]::MaxValue % [uint32]$MaxExclusive)
$bytes = New-Object -TypeName byte[] -ArgumentList 4
do {
$rng.GetBytes($bytes)
$value = [System.BitConverter]::ToUInt32($bytes, 0)
} while ($value -ge $limit)
return [int]($value % [uint32]$MaxExclusive)
}
function New-RandomComplexPassword {
param([int]$Length)
# Look-alike characters (I, l, O, o, 0, 1) are left out so the password can be read over the phone.
$sets = @(
'ABCDEFGHJKLMNPQRSTUVWXYZ',
'abcdefghijkmnpqrstuvwxyz',
'23456789',
'!@#$%^&*-_=+'
)
$allChars = -join $sets
# One character from each class, then fill the rest from the combined set.
$chars = New-Object -TypeName System.Collections.Generic.List[char]
foreach ($set in $sets) {
$chars.Add($set[(Get-RandomIndex -MaxExclusive $set.Length)])
}
while ($chars.Count -lt $Length) {
$chars.Add($allChars[(Get-RandomIndex -MaxExclusive $allChars.Length)])
}
# Fisher-Yates shuffle so the guaranteed characters are not always at the front.
for ($i = $chars.Count - 1; $i -gt 0; $i--) {
$j = Get-RandomIndex -MaxExclusive ($i + 1)
$swap = $chars[$i]
$chars[$i] = $chars[$j]
$chars[$j] = $swap
}
return -join $chars
}
$defaultPolicy = Get-ADDefaultDomainPasswordPolicy
$accounts = @(Import-Csv -Path $CsvPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_.SamAccountName) })
$report = New-Object -TypeName System.Collections.Generic.List[object]
$successCount = 0
foreach ($account in $accounts) {
$samAccountName = $account.SamAccountName.Trim()
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
# Double any single quote so a name such as o'brien can't break out of the filter string.
$filterName = $samAccountName -replace "'", "''"
$adUser = Get-ADUser -Filter "SamAccountName -eq '$filterName'" -Properties LockedOut, PasswordNeverExpires, Enabled
if ($null -eq $adUser) {
Write-Warning "$samAccountName not found in Active Directory - skipped."
$report.Add([PSCustomObject]@{ SamAccountName = $samAccountName; TemporaryPassword = ''; Result = 'Not found'; ResetDate = $timestamp })
continue
}
# Decided before ShouldProcess so -WhatIf and -Confirm show which accounts won't be forced to change.
$forceChange = $ClearPasswordNeverExpires -or -not $adUser.PasswordNeverExpires
$operation = if (-not $forceChange) {
'Reset password only; PasswordNeverExpires is set, so change at next logon is NOT forced'
} elseif ($adUser.PasswordNeverExpires) {
'Clear PasswordNeverExpires, reset password and force change at next logon'
} else {
'Reset password and force change at next logon'
}
if (-not $PSCmdlet.ShouldProcess($samAccountName, $operation)) {
continue
}
$passwordSet = $false
try {
# Effective minimum length: a fine-grained PSO wins over the domain policy when one applies.
$policy = Get-ADUserResultantPasswordPolicy -Identity $adUser
if (-not $policy) {
$policy = $defaultPolicy
}
$length = [math]::Max($PasswordLength, [int]$policy.MinPasswordLength)
$newPassword = New-RandomComplexPassword -Length $length
$securePassword = ConvertTo-SecureString -String $newPassword -AsPlainText -Force
Set-ADAccountPassword -Identity $adUser -NewPassword $securePassword -Reset -ErrorAction Stop
$passwordSet = $true
$result = 'Reset'
if ($adUser.PasswordNeverExpires -and $ClearPasswordNeverExpires) {
Set-ADUser -Identity $adUser -PasswordNeverExpires $false -ErrorAction Stop
$result += '; PasswordNeverExpires cleared'
}
if (-not $forceChange) {
Write-Warning "$samAccountName has PasswordNeverExpires set; password reset but ChangePasswordAtLogon NOT set. Re-run with -ClearPasswordNeverExpires or handle it by hand."
$result += '; NOT forced (PasswordNeverExpires)'
} else {
Set-ADUser -Identity $adUser -ChangePasswordAtLogon $true -ErrorAction Stop
$result += '; must change at next logon'
}
$message = "Reset ${samAccountName}: ChangePasswordAtLogon set"
if ($adUser.LockedOut) {
Unlock-ADAccount -Identity $adUser -ErrorAction Stop
$result += '; unlocked'
$message += ', account unlocked'
}
if (-not $adUser.Enabled) {
$result += '; account is disabled'
}
if ($result -notmatch 'NOT forced') {
Write-Host "$message."
}
$report.Add([PSCustomObject]@{ SamAccountName = $samAccountName; TemporaryPassword = $newPassword; Result = $result; ResetDate = $timestamp })
$successCount++
} catch {
if ($passwordSet) {
# The password did change, so keep it in the report; only the follow-up step failed.
Write-Warning "Password for $samAccountName was reset, but a follow-up step failed: $($_.Exception.Message)"
$report.Add([PSCustomObject]@{ SamAccountName = $samAccountName; TemporaryPassword = $newPassword; Result = "Reset, then failed: $($_.Exception.Message)"; ResetDate = $timestamp })
} else {
Write-Warning "Failed on ${samAccountName}: $($_.Exception.Message)"
$report.Add([PSCustomObject]@{ SamAccountName = $samAccountName; TemporaryPassword = ''; Result = "Failed: $($_.Exception.Message)"; ResetDate = $timestamp })
}
}
}
if ($report.Count -gt 0) {
$report | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
}
$rng.Dispose()
if ($WhatIfPreference) {
Write-Host "WhatIf run: no passwords were changed."
return
}
$failed = $accounts.Count - $successCount
Write-Host "$successCount of $($accounts.Count) account(s) reset, $failed failed or skipped. Report written to $ReportPath"
Notes
- The report contains plaintext passwords. Write it to a folder only the operator can read, move it over an already-encrypted channel (a password manager's secure share, not email or chat), and delete it once every user has signed in and changed their password. If you don't need to hand passwords out, because users will go through self-service reset or the help desk will set a new one on the phone, remove the
TemporaryPasswordcolumn from the report. Set-ADAccountPassword -Resetrequires-NewPasswordand does not need the old one. The new password still has to satisfy the effective policy's length and complexity rules, or the cmdlet throws and the account lands in the report as failed. That's why the generator readsMinPasswordLengthfromGet-ADUserResultantPasswordPolicy(which returns nothing when no fine-grained policy applies) and falls back toGet-ADDefaultDomainPasswordPolicy.ChangePasswordAtLogon $truewritespwdLastSet = 0. It's also what makesPasswordExpiredreadTrueuntil the user changes it, which is why the verification snippet above is a reliable check.- Don't put service accounts in the list without a plan. A service account can't answer a "change your password at next logon" prompt, so forcing the change on one will break whatever runs under it until someone sets a new password in AD and on every service, scheduled task or application pool that uses it. For those, reset to a known value and update the consumers in the same change window, or better, move them to group managed service accounts:
Set-ADAccountPasswordcan't set a gMSA password at all, because the domain rotates it on its own schedule. - A password reset does not end sessions that already exist. Kerberos tickets that were issued before the reset stay valid until they expire, and cloud sessions are separate: if the accounts sync to Entra ID and the incident calls for it, also revoke refresh tokens with
Revoke-MgUserSignInSession -UserId <user-id>(Microsoft Graph PowerShell,User.RevokeSessions.All). - The script reports disabled accounts but still resets them. That's deliberate for a breach response, where a disabled account with a known password is still a risk if someone re-enables it.
ConfirmImpact = 'High'means PowerShell asks for confirmation on each account unless you pass-Confirm:$false. For a long list, run-WhatIffirst, review, then run with-Confirm:$false.