A small library of standalone PowerShell scripts that keep Active Directory healthy and secure, designed to run unattended via Scheduled Tasks with no per-run busy-work.
These scripts make real changes to Active Directory (disabling, moving and deleting accounts) and send e-mail on your organisation's behalf. Automation is only safe when you stay in control of it:
- Know what you are automating. Only point a job at a part of the directory you have under control — you should be able to predict what a run will touch before it runs.
- Align the settings with your organisation's policy. The defaults (thresholds, OUs, ignore lists, caps) are generic starting points, not policy. Review every value in
config\AD-Automation.settings.psd1against your own account-lifecycle rules before scheduling anything — otherwise a run can have unexpected consequences.- Test in a controlled environment first. Run the whole setup in a lab or test domain (or at least against a small, non-critical OU) before pointing it at production, so you see exactly how the jobs behave with your policy settings — without production consequences.
- Always start with a dry run. Every job supports
-DryRun: it changes nothing and writes a CSV report + log showing exactly what a live run would have done. Review that output first, and only then switch the job to-Run/-Scheduled.
What it is. Drop-in scripts for AD hygiene (disable/delete stale objects), user notifications (password expiry, lockouts, pending disable) and password security (duplicate-password + Have I Been Pwned checks). One external settings file configures everything — you never edit a script for your environment.
Three principles: configure once (one .psd1 file) · works with any AD
(auto-detects the domain) · safe & simple by default (dry-run, automatic caps,
no lists to curate).
Get going in 3 steps:
Copy-Item config\AD-Automation.settings.sample.psd1 config\AD-Automation.settings.psd1
notepad config\AD-Automation.settings.psd1 # set SMTP, recipients, OUs
.\Invoke-ADHygieneDisableInactive.ps1 -DryRun # preview - changes nothingThen schedule everything at once:
.\Install-ADAutomationScheduledTask.ps1 -UseSystem -ListOnly # preview tasks
.\Install-ADAutomationScheduledTask.ps1 -UseSystem # register themWhat to run, and how often:
| Script | Recommended interval | Mode (live) | What it does |
|---|---|---|---|
Invoke-ADPasswordExpiryNotify.ps1 |
Daily (07:00) | -Run |
Warn users before their password expires |
Invoke-ADDisableInactiveWarning.ps1 |
Daily (07:30) | -Run |
Warn manager + extras before an inactive user is disabled |
Invoke-ADLockoutNotify.ps1 |
Every 15 min | -Run |
Alert on account lockouts (event 4740) |
Invoke-ADDuplicatePasswordNotify.ps1 |
Every 30 min or event 4720 | -Run |
Alert when a new account reuses another's password |
Invoke-ADPasswordChangeAudit.ps1 |
Every 30 min or event 4723/4724 | -Run |
On password change: duplicate + pwned checks |
Invoke-ADHygieneDisableInactive.ps1 |
Daily (02:00) | -Scheduled |
Disable inactive users/computers, move to a Disabled OU |
Invoke-ADHygieneDeleteDisabledUsers.ps1 |
Weekly (Sun 03:00) | -Scheduled |
Delete users disabled for ≥ N days |
Invoke-ADServerGroupProvisioning.ps1 |
Daily (04:00) | live = no switch | Create per-server Admin/RDP groups, nest baselines |
The three AD-writing jobs install in preview first —
DisableInactive/DeleteDisabledin-DryRun,ServerGroupsin-WhatIf. Review the report/log, then switch them live (-Scheduledfor the hygiene jobs, remove-WhatIfforServerGroups). No approval list is required; the disabled-for-N-days threshold and per-run caps are the automatic safety net. Notifications need only a working SMTP relay. The two password jobs additionally need DSInternals + replication rights and should run on/near a DC.
Full setup, per-script details, security model and event-driven triggers are below and in docs/SCHEDULED-TASKS.md.
| Script | Purpose | Modes |
|---|---|---|
Invoke-ADHygieneDisableInactive.ps1 |
Disable inactive users (standard + service) & computers; optionally move them to a "Disabled" OU | -DryRun / -Run / -Scheduled |
Invoke-ADHygieneDeleteDisabledUsers.ps1 |
Delete users that have been disabled for N days | -DryRun / -Run / -Scheduled |
Invoke-ADDisableInactiveWarning.ps1 |
Warn the manager (and extra addresses) before a user is disabled | -DryRun / -Run |
Invoke-ADLockoutNotify.ps1 |
E-mail on account lockouts (event 4740), across all DCs, de-duplicated | -DryRun / -Run |
Invoke-ADPasswordExpiryNotify.ps1 |
E-mail users before their password expires | -DryRun / -Run |
Invoke-ADDuplicatePasswordNotify.ps1 |
Alert when a new account reuses another account's NTLM password | -DryRun / -Run |
Invoke-ADPasswordChangeAudit.ps1 |
On password change: duplicate-password + Have I Been Pwned checks | -DryRun / -Run |
Invoke-ADServerGroupProvisioning.ps1 |
Create per-server Admin/RDP groups and nest baseline groups | -WhatIf aware |
Install-ADAutomationScheduledTask.ps1 |
Register all of the above as Scheduled Tasks | -WhatIf / -ListOnly |
ADAutomation.psm1 / .psd1 |
Shared module (config, logging, mail, hashing, state) | — |
- Windows PowerShell 5.1 (or PowerShell 7+) on a domain-joined host.
- RSAT ActiveDirectory module (
Get-Module -ListAvailable ActiveDirectory). - DSInternals module only for the two password-hash scripts
(
Install-Module DSInternals). - A run-as account with the right permissions — see docs/SCHEDULED-TASKS.md.
All scripts read the same file. Resolution order:
-ConfigPath <file>parameter, then- the
AD_AUTOMATION_CONFIGenvironment variable, then config\AD-Automation.settings.psd1(your real file), then…json, thenconfig\AD-Automation.settings.sample.psd1(the committed sample).
Inside the file, the Common section applies to every script and each
per-script section (e.g. DisableInactive) overrides it. Every key matches a
script parameter name exactly.
Precedence: built-in default < config file < value passed on the command line. So the config file sets defaults for everything at once, and any parameter you pass on the command line still wins.
# settings.psd1 (excerpt)
@{
Common = @{
SmtpServer = 'smtp-relay.corp.local'
MailFrom = 'ad-automation@corp.local'
AdminMailTo = @('it-ops@corp.local')
OutputRoot = 'C:\ProgramData\AD-Automation'
}
DisableInactive = @{
UserInactiveForDays = 90
UserLimitToOUs = @('OU=Users,DC=corp,DC=local')
}
DuplicatePasswordNotify = @{ MailTo = @('security@corp.local') }
}The file is read with
Import-PowerShellDataFile, which only parses literals — no code executes, so it is safe on a Domain Controller. A.jsonfile with the same structure also works.
Put your relay and recipients in Common; override per script where needed
(e.g. DuplicatePasswordNotify.MailTo, DisableInactiveWarning.AdditionalMailTo).
For TLS set SmtpPort = 587 and MailUseSsl = $true. For an authenticated relay,
store a credential once (DPAPI-encrypted to the run-as account + machine):
Get-Credential | Export-Clixml 'C:\ProgramData\AD-Automation\smtp.cred'
# config: MailCredentialPath = 'C:\ProgramData\AD-Automation\smtp.cred'| Script | Interval | Why this cadence |
|---|---|---|
| Password expiry notify | Daily | Uses discrete day thresholds (e.g. 14/7/3/1) — must run once per day to hit them. |
| Disable-inactive warning | Daily | Same discrete-threshold logic (WarnDays = 14/7/1). Run it before the disable job. |
| Lockout notify | Every 15 min | Near-real-time alerting. A per-DC event-id watermark prevents duplicate mails. |
| Duplicate-password notify | Every 30 min or on event 4720 | Catch a reused password on a new account quickly. Event trigger = instant, per account. |
| Password-change audit | Every 30 min or on event 4723/4724 | Catch a weak/breached new password quickly after the change. |
| Disable inactive | Daily (off-hours) | Hygiene only; daily is ample. Start in -DryRun, then -Scheduled. |
| Delete disabled users | Weekly (off-hours) | Low churn; weekly avoids noise. Start in -DryRun, then -Scheduled. |
| Server group provisioning | Daily or on-demand | New servers get their groups by the next day; idempotent so re-runs are cheap. |
Set
-LookbackMinutes≥ the lockout run interval (the installer uses a 15-min interval with-LookbackMinutes 20), so a delayed or skipped cycle is still covered; the per-DC high-water mark prevents duplicate mail on the overlap, and a missed run is caught up from the last successful run. The installer wires these cadences up for you; see docs/SCHEDULED-TASKS.md for manualRegister-ScheduledTaskexamples and event-driven triggers.
Most scripts accept -DryRun (preview) and write a timestamped log + CSV to
OutputRoot (default C:\ProgramData\AD-Automation, whose ACL is hardened on every
run — best-effort — to SYSTEM, Administrators and the run-as account). The exception
is Invoke-ADServerGroupProvisioning.ps1: its preview switch is -WhatIf (it has no
-DryRun and writes only a log, no CSV).
Invoke-ADPasswordExpiryNotify.ps1— e-mails users whose password expires withinNotifyWindowDays, or exactly onNotifyDaysthresholds. Sends an admin summary CSV toAdminMailTo. Daily.Invoke-ADDisableInactiveWarning.ps1— projects each inactive user's disable date and warns atWarnDays(default 14/7/1). Recipients = the account'smanagermail plusAdditionalMailTo. Ifmanageris empty it simply continues — it still notifies the extra addresses and logs the gap; it never stops. Read-only (no account changes). KeepUserInactiveForDaysequal to the disable job's value. Daily.Invoke-ADLockoutNotify.ps1— reads event 4740 from the PDC emulator (or every DC withAllDomainControllers = $true), e-mails admins and optionally the locked-out user. A per-DCRecordIdhigh-water mark prevents duplicate mails on overlapping runs. Every 15 min.
Invoke-ADHygieneDisableInactive.ps1— disables users/computers inactive past their threshold and optionally moves them to a Disabled OU (leave the move OU empty to disable in place).MaxChangescaps objects changed per run. Daily,-DryRun→-Scheduled.Invoke-ADHygieneDeleteDisabledUsers.ps1— deletes users disabled for ≥DisabledForDays(default 180). Safety is automatic: the threshold, a per-runMaxDeletescap (default 25;-Unlimitedlifts it), ignore lists, and skipping any account whose disable date can't be reliably determined. No approval list to maintain —-RequireApprovalListexists only if you want manual change control. Weekly,-DryRun→-Scheduled.
Invoke-ADDuplicatePasswordNotify.ps1— alerts when a newly created account is given a password (NTLM hash) already used by another account. Tracks new accounts between runs (first run = baseline only). Accepts-SamAccountNamefor event-driven (4720) runs. E-mailsMailTo. Every 30 min or on 4720.Invoke-ADPasswordChangeAudit.ps1— when a user changes their password, runs a duplicate-password check and a Have I Been Pwned check on the new hash. TrackspwdLastSetbetween runs (first run = baseline). Accepts-SamAccountNamefor event-driven (4723/4724) runs. E-mailsMailTo. Every 30 min or on 4723/4724.
Only Invoke-ADPasswordChangeAudit.ps1 contacts Have I Been Pwned, and it uses
k-anonymity (only the first 5 hex chars of a hash ever leave the host);
Invoke-ADDuplicatePasswordNotify.ps1 makes no outbound calls and compares hashes
locally. Neither script e-mails raw NTLM hashes — reports and mail carry only an
opaque per-run group label (e.g. grp:1A2B3C4D) that correlates accounts sharing a
password without exposing any hash bits. Full hashes hit disk only with
-WriteHashCsv (never during -DryRun).
Invoke-ADServerGroupProvisioning.ps1— for each computer inComputerOUs, createsSRV-<name>-Administrators/-RemoteDesktopUsersand nests the baseline groups. Idempotent,-WhatIf-aware, pins one DC so create-then-read never races replication. Daily or on-demand.
- Dry-run first.
-DryRunchanges nothing and writes a CSV + log. - Automatic, low-friction guardrails. Day thresholds + per-run caps
(
MaxChanges/MaxDeletes, both default 25) + ignore lists protect you without any list to maintain. An optional-RequireApprovalListis there only if you want it. - Restricted output.
OutputRoot's ACL is hardened on every run (best-effort) to SYSTEM, Administrators and the run-as account — including when the folder was pre-created. - TLS-capable, UTF-8 mail. Swedish characters (å ä ö) survive; set
MailUseSsl/MailCredentialPathto encrypt and authenticate. - No secrets leaked. Password jobs mask hashes and use HIBP k-anonymity.
Every run logs three ways:
- Console — colour-coded when run interactively.
- File — a timestamped
.log(plus CSV report) per run underOutputRoot. - Windows Event Log — mirrored to Event Viewer → Applications and Services
Logs →
AD-Automation, with one event source per script (filter by Source for a per-script view — Windows limits custom log names to 8 significant characters, so separate logs per script would collide; sources are the supported way to separate them). Event IDs:1000INFO,1001ACTION,1002WHATIF,1003SKIP,2000WARN,3000ERROR — so you can attach a Task Scheduler alert or SIEM rule to, say, every3000in this log.
The event log and its sources are registered automatically by the installer
(elevated) or on the first run as SYSTEM/gMSA; disable the mirroring with
EventLogEnabled = $false in [Common]. Query it from PowerShell:
# Everything the delete job logged today
Get-WinEvent -FilterHashtable @{
LogName = 'AD-Automation'; ProviderName = 'ADHygiene-DeleteDisabledUsers'
StartTime = (Get-Date).Date
} | Format-Table TimeCreated, Id, Message -AutoSize
# All errors from any AD-Automation script in the last 7 days
Get-WinEvent -FilterHashtable @{ LogName = 'AD-Automation'; Level = 2; StartTime = (Get-Date).AddDays(-7) }# Preview the whole task set (no changes)
.\Install-ADAutomationScheduledTask.ps1 -GmsaUser 'CORP\adauto$' -ListOnly
# Install the notification jobs as SYSTEM on a DC
.\Install-ADAutomationScheduledTask.ps1 -UseSystem -Include PasswordExpiry,Lockout,DisableWarningPrefer to import by hand (or deploy by GPO)? Ready-made, schema-valid task XML —
one per script, same cadence as the installer — is in
scheduled-tasks/: import via Task Scheduler's
"Import Task…", Register-ScheduledTask -Xml, or schtasks /xml. Regenerate them
with scheduled-tasks/Build-TaskXml.ps1 to
change the run-as account, script path, or schedule. See that folder's
README.
Run-as options, a least-privilege gMSA, per-task manual Register-ScheduledTask
examples, the recommended schedule, and event-driven triggers for the password
jobs are all in docs/SCHEDULED-TASKS.md.
All scripts use the approved PowerShell Verb-Noun form prefixed Invoke-AD…
so they sort and read consistently. (create_computer_groups.ps1 was renamed to
Invoke-ADServerGroupProvisioning.ps1.)