Back in July 2025 I had a simple task: find and remove orphaned SIDs in Active Directory. A one-off script did it. Then I kept going, and the script grew logging, batching, memory limits, a restore path and thousands of Pester tests, all of which I described at the time as completely over-engineered. I wasn’t wrong.
It came out of cleaning up an old AD forest (their krbtgt password hadn’t been rotated in over 8 years), where I found orphaned SIDs everywhere.
It lived as Find-UnknownSID.ps1 in my big scripts repo for over a year. This weekend it became OrphanedSid, its own repository and a module on the PowerShell Gallery. The rest of this series covers how badly that conversion went in places. This post is for you if you just want to use the thing.
If you need the background on what an orphaned SID is, my earlier post covers it. The short version: delete a user or group and every access control entry that named it stays behind, pointing at a SID nothing can resolve anymore.
Installing it
The module targets Windows PowerShell 5.1 and PowerShell 7 on Windows. It needs the RSAT ActiveDirectory module, a domain-joined machine that can reach a domain controller, and rights that match what you’re doing: read for discovery, Modify Permissions on the target objects for removal and restore.
If RSAT is missing, it’s one line on either kind of machine:
You might expect ActiveDirectory in the manifest’s RequiredModules. It’s empty, and the comment above it explains why:
So the module imports fine on a box without RSAT, and each command stops at startup when it can’t find what it needs. That also lets the test suite run on a GitHub runner with no domain anywhere near it, which comes up later in the series.
Three functions come out the other end:
| |
Those replace the old script’s discovery, -Remove and -Restore parameter sets. Each one writes objects to the pipeline now, and -OutputPath still gives you a CSV when somebody wants a spreadsheet.
My lab for this run
Everything below came from the published 1.0.1 package, pulled with Save-Module onto a Windows Server 2022 domain controller in my homelab, running Windows PowerShell 5.1.20348. The session ran as the DC’s own computer account, because I reach that VM through the Proxmox guest agent. Yours will say your account.
The test data comes from my TestEnvironment module. New-ADTestEdgeCase OrphanedSid builds two groups under a throwaway OU, each with an ACE for a SID that has never belonged to anything. I covered that module’s AD side in Active Directory Test Data, a Year and Six Providers Later.
The TestEnvironment module spun out of me constantly building and tearing down to test this SID script (and me just adding more and more to it).
Finding what’s orphaned
Discovery changes nothing, so it’s where you start:
The console shows progress as it goes. I’ve trimmed the lines that bracket the run:
| |
Each orphaned entry is one object on the pipeline. These are the first few properties of one of mine:
LikelySource is a guess from the SID itself. A RID of 90001 is high enough that the analysis calls it a bulk import or migration artifact, and in this case the “migration” was me seeding a lab, so take it as a hint.
Since these are objects, you can do normal PowerShell things with them. Point it at every top-level OU and see which dead SIDs show up the most:
-IncludeInherited adds entries inherited from a parent. Leave it off and you see the entries set on each object directly, which is the list you can do something about at that object.
How it decides a SID is orphaned
Test-OrphanedSID tries several lookups and treats a successful one as proof the principal exists. A format check and a per-run cache come first, then an objectSid search, then a .NET translation that also covers anything reachable over a trust:
| |
If it finds the SID in the deleted objects container, that counts as orphaned too. Well-known SIDs never get this far: Everyone, Authenticated Users, the built-in groups, SYSTEM and friends are matched by patterns in the configuration and skipped.
One thing to know before you trust the list. Every lookup has a catch-all, and if all of them fall through, the function’s last line of reasoning is this:
A SID from a domain whose trust is down, or a lookup that failed because a DC went away mid-run, lands in the same pile as a dead one. I read the CSV before I remove anything, and I’d want you to as well, especially in a forest with trusts.
Previewing with -WhatIf
Remove-OrphanedSid has ConfirmImpact = 'High', so it prompts once before touching anything. -WhatIf runs the whole discovery and tells you what it would do:
| |
| |
The summary at the end of a -WhatIf run is misleading if you skim it:
Nothing was removed. Each result object says so in its SecurityValidation property, WhatIf - Would Remove, and I ran discovery again straight after to check. It still found 2.
Removing them
An unattended run needs -Force, because a scheduled task has nobody to answer the prompt. -CorrelationId is optional. By default you get a GUID, but I like passing a change number so the log, the backups and the results all tie back to the ticket:
One result per modified object:
| |
BlockedSIDs is where a protected SID would show up. S-1-5-32-544 (BUILTIN\Administrators), Users, Guests, SYSTEM, LOCAL SERVICE and NETWORK SERVICE are on a list that removal won’t touch even if a lookup can’t resolve them. You can extend that list, along with the well-known patterns, in a JSON file passed to -ConfigPath.
Discovery against the same OU afterward returned 0.
What the backup holds
Before each change, the module writes one Clixml file for that object into a timestamped folder:
Skip -BackupPath and they go to .\Backup\<timestamp>\ under your current directory. Logs do the same under .\Logs\, so think about where you’re standing when you run it.
New-ACLBackup stores the whole security descriptor as SDDL, with a hash next to it:
The file also records who ran it, from where, and the correlation ID. Restore checks the hash before it applies anything, so a hand-edited backup fails validation. Because it’s the full descriptor, a restore puts back everything on that object as of the backup, including any change somebody made in between. I’d keep that in mind if a week goes by.
Putting it back
Restore takes a target DN and the run’s backup folder. Give it a container and it restores every object at or under that container that has a backup there:
| |
Sixty-eight entries sounds odd for two orphans. That’s the whole ACL on each group, orphan included. Discovery found the same 2 SIDs on the same 2 groups again, and the lab was back where it started.
Rolling back one object at a time
Most of the time I wouldn’t want the whole OU back. A removal’s own CSV lists every object it touched, so you can filter it down to the ones you care about and pipe those in:
I ran the same pipeline against both groups in an earlier remove-and-restore cycle, so the backup folder in this output is that cycle’s, 20261108_201750. Each object gets its own result, and BackupFile tells you which file it came from:
| |
The match runs on the ObjectDN recorded inside each backup, compared in full and ignoring case. File names are only a sanitized copy of the DN, so CN=User1 can look a lot like the start of CN=User10, and the same CN can turn up in any number of OUs. If a folder holds several backups of one object, you get the newest.
Logs for the audit trail
Every line of a run carries the correlation ID:
| |
Security events land in the same file as JSON lines, and they’re written at every -LogLevel, so filtering down to warnings doesn’t lose the audit record. Finding one run is a Select-String:
| |
-SuppressConsoleOutput keeps a scheduled run quiet without touching the file.
Running it on a schedule
Discovery is the part I’d automate. The README’s scheduled task example mails a report when it finds anything:
| |
The @() around the call is for 5.1, where a single result comes back without a Count you can lean on. That’s the shape I’d run this in: find on a schedule, remove by hand after somebody’s read the CSV.
If your environment needs different limits, -ConfigPath takes a JSON file. It reads only a short list of top-level keys: WellKnownSIDPatterns, ProtectedSIDs, BatchSize (1 to 1000, default 50), MemoryCheckInterval (1 to 1000, default 50), LogLevel, and the logging settings. Everything else is a parameter. Results stream to batch files on disk as they’re found, which keeps memory flat on a big OU, and those files get cleaned up at the end unless you pass -PreserveTempFiles.
Three commands is all the surface this module has. Behind them are 57 private function files and a suite that passes 2,467 tests on Windows PowerShell 5.1.
The module is on GitHub, MIT licensed, and I’d like to hear what it finds in your domain.
