Field notes · powershell
Revoke access everywhere: the offboarding ghost hunt
Disabling the account is step one. Here are the 7 places a user's access hides, the inventory that finds them, and the stale-login report that catches misses.
In my offboarding post I built the day-of-departure script: disable the account, kill the sessions, convert the mailbox, reclaim the license. That script handles the identity you manage. This post is about the access you don’t. To fully revoke access for a user, you have to walk every system where they ever got a login, and most of those systems never heard of your directory.
I learned this the embarrassing way. A year after a project wrapped, I logged into a vendor’s billing portal and found my own long-retired test account still listed as an administrator. Nobody had touched it. Nobody would have noticed if someone did. If I couldn’t keep my own test account off that list, a departing employee had no chance.
Ghost access is my name for that leftover: a credential that keeps working after the person it belonged to is gone. Here is where ghosts hide, the inventory that hunts them, and the report that catches the ones you miss anyway.
Key takeaways
- Disabling the directory account revokes one credential. Most people hold seven kinds.
- An access inventory is built once per system, not once per departure. Then it’s a checklist.
- Group-based access turns VPN and Wi-Fi revocation into a side effect of the script you already run.
- Shared and vendor-portal credentials can’t be disabled, only rotated. Rotate on every departure.
- A monthly stale-login report is the safety net for whatever the checklist missed.
Why isn’t disabling the account enough?
Because the directory only controls what was wired to it. Single sign-on gets you a long way, and in a small shop plenty of systems never made it that far: the vendor portal with its own password, the door controller with its own database, the shared login taped inside a binder. Disable the account and every one of those keeps working.
The pattern from the day-of-departure post holds. Onboarding failures are loud and offboarding failures are quiet, and unfederated systems are the quietest of all. There is no sign-in log you watch, no license line item, often no other admin. In a one-person shop, if you don’t go looking, nobody does.
Where does a user’s access hide?
In seven places, in my experience. The left column is the inventory; the right column is why each one survives the day-of-departure script.
| Where access lives | Why it survives the script |
|---|---|
| Directory account and SSO apps | It doesn’t. This is the part the script handles. |
| Live cloud sessions and tokens | Covered in the offboarding post: revoke explicitly, don’t wait for expiry. |
| Network access (VPN, Wi-Fi) | Only dies with the account if it’s group-based. Local accounts on the firewall don’t. |
| SaaS and vendor portals outside SSO | Separate credentials the vendor manages. Nothing you disable touches them. |
| Shared credentials | Not tied to any person, so no departure event ever disables them. |
| Physical access (badges, keys, alarm codes) | Separate system, separate database, usually managed by someone who isn’t you. |
| Mail flows out (forwarding rules, delegations) | Survive on the mailbox itself, even after you convert it to shared. |
How do you build an access inventory?
Once per system, write down three things: what the system is, how a person’s access gets revoked, and who does it. That’s the whole method. The work is not clever, it’s just honest, and you only do it when a system enters your environment, not every time a person leaves. Departures then become a checklist run instead of a memory test. It is the same trade I made in onboarding: pay a small setup cost once so the day itself needs no memory.
My inventory lives in a spreadsheet with about two dozen rows. Each row took a minute to write and each one represents a system I would otherwise have to remember at the worst possible moment. The revoke column is blunt: “remove from VPN group,” “email vendor support to delete the login,” “rotate the shared password.” If I couldn’t write the revoke step for a row, that was the finding; it meant nobody could revoke it.
Two rows surprised me when I first walked the building. Our door badge system doesn’t talk to the directory at all; nothing I disable in AD touches it, and the person who manages badges isn’t in IT. And the alarm panel had a code assigned per person, which nobody had ever retired. Neither appears in any sign-in log. Both are exactly what a checklist is for.
What can the script revoke on its own?
Everything that’s group-based. This is the design decision that pays off at departure time: if the firewall grants VPN by RADIUS group and the wireless controller grants staff Wi-Fi the same way, then the group-stripping step already in the day-of-departure script revokes both. No firewall login, no second procedure. The network access dies as a side effect.
# Group-based access means the day-of-departure group strip already revoked
# VPN and Wi-Fi. Keep the record it wrote; it is your revocation proof.
$user = Get-ADUser -Identity $sam -Properties MemberOf
$user.MemberOf |
Out-File -FilePath "$logDir\offboard-$sam-groups.txt" -Encoding UTF8
The other automatable ghost is mail flow. A forwarding rule quietly sends company mail to an outside address for as long as it exists, and it lives on the mailbox, so converting the mailbox to shared does not remove it. Check both places forwarding can hide, mailbox-level forwarding and Get-InboxRule:
# Forwarding set on the mailbox itself
Get-Mailbox -Identity $upn |
Select-Object ForwardingAddress, ForwardingSmtpAddress
# Forwarding buried in inbox rules
Get-InboxRule -Mailbox $upn |
Where-Object { $_.ForwardTo -or $_.RedirectTo -or $_.ForwardAsAttachmentTo } |
Select-Object Name, ForwardTo, RedirectTo
I added the inbox-rule check after finding one in the wild: a converted mailbox that had been quietly redirecting a category of messages to a personal address. The rule predated the departure and nothing in my process would have caught it. Now every offboarding run prints these two checks and I read the output.
How do you revoke access to systems the script can’t reach?
By hand, from the inventory, and for shared credentials the verb changes: you can’t disable them, only rotate them. Credential rotation is changing a password everywhere it’s used so that everyone who knew the old one is locked out at once. Any shared login the user could see gets rotated on their last day. A password manager makes this survivable, because “every entry they could see” becomes a list you can actually produce instead of a guess.
Vendor portals get an email or a support ticket: delete this user. I ask for confirmation and paste it into the departure ticket, because a vendor “sure, done” with no evidence is how my test account stayed an administrator for a year. Physical access is a handoff, not an email. The badge deactivation and the alarm code retirement each have a named owner in the inventory, and the checklist isn’t done until they confirm.
None of this is automation. It’s still the same system as the day-of-departure script: the checklist is in one place, the script prints it, and a step that lives in the checklist happens on the tenth departure, not just the first nine.
How do you catch the ghosts you miss?
With a report that assumes you failed. The checklist handles the departure you know about; the safety net is a monthly stale-login report for the ones that slipped through: enabled directory accounts that haven’t signed in for 30 days.
# Enabled accounts nobody has used in 30+ days. Every line is a question.
$cutoff = (Get-Date).AddDays(-30).ToFileTime()
Get-ADUser -Filter { Enabled -eq $true -and LastLogonTimestamp -lt $cutoff } `
-Properties LastLogonTimestamp, whenCreated |
Select-Object Name, SamAccountName,
@{n='LastLogon'; e={ [DateTime]::FromFileTime($_.LastLogonTimestamp) }} |
Sort-Object LastLogon
One honesty note on the data: Microsoft documents that LastLogonTimestamp replicates lazily, by default up to 14 days behind the true last logon. That makes it wrong for forensics and fine for this job, where the question is “has anyone used this in a month,” not “when exactly.”
Most rows are boring: a service account, someone on leave, a seasonal position. That’s fine. The report’s job is to make every stale account a question someone answers, instead of a silence nobody notices. The one time it caught a real miss, a departure I was never told about, it paid for every boring month before and since.
For the unfederated systems the report can’t see, I do the manual version quarterly: open each vendor portal from the inventory and read its user list. Fifteen minutes, a recurring calendar reminder, and it has never once come back completely clean.
What broke, and what I would change
The inventory goes stale, and it goes stale silently, which by now you’ll recognize as the theme of this entire offboarding system. Every new SaaS signup adds a row nobody wrote. My fix is boring: adding the inventory row is part of standing the system up, the same way DNS is. I would not trust myself to backfill it later, because I have the backlog to prove I don’t.
The other lesson: I automated too little for too long because the unfederated systems felt unautomatable. They mostly are. But the checklist that names them is itself the automation that matters. The value was never the PowerShell; it’s that nothing depends on me remembering.
FAQ
What is ghost access?
Ghost access is any credential that keeps working after its owner leaves: a vendor portal login, a shared password they knew, a badge, a forwarding rule. It survives because it lives outside the directory you disable, so revoking it takes an inventory, not just a script.
How do you revoke access to systems that don’t use single sign-on?
From a per-system inventory that records how each system’s access is revoked and who does it. Personal logins get deleted by the vendor with confirmation in writing; shared credentials get rotated; physical access gets a named owner who confirms deactivation.
Should shared passwords be rotated when an employee leaves?
Yes, every one the person could see, on their last day. A shared credential can’t be disabled because it isn’t tied to a person. A password manager makes the “which ones could they see” question answerable; without one you’re guessing.
How do you find accounts that were never offboarded?
Run a recurring report of enabled accounts with no sign-in for 30 days and make a human answer for every row. For systems outside the directory, review each vendor portal’s user list on a quarterly reminder. Both exist to catch the departures the process missed.