When I pick up a Microsoft 365 tenant, whether it's new to me or one I haven't looked at closely in a while, I don't start by changing anything. I start by asking it questions. Three questions in particular have given me the most value for the least risk:
- Where are the licenses going?
- Which accounts nobody is using anymore?
- Is any mail quietly leaving the organization?
I packaged the scripts I use for these into a small open-source toolkit: m365-powershell-toolkit on GitHub, also available as a module on the PowerShell Gallery. All three are read-only. They report, they don't change anything, and they ask for the least-privileged scopes I could get away with.
Getting set up
Install-Module Microsoft.Graph -Scope CurrentUser
Install-Module ExchangeOnlineManagement -Scope CurrentUser
# Either clone the repo and run the .ps1 files, or install the module:
Install-Module M365AdminToolkit -Scope CurrentUser
PowerShell 7.2+ is what I use day to day, but Windows PowerShell 5.1 works for most of it. An account with Global Reader or Reports Reader is enough; you don't need Global Admin to run any of these. Every function has comment-based help, so Get-Help Get-StaleEntraUsers -Full is the fastest way to see the options.
1. License usage: Get-M365LicenseReport
Licenses are usually the biggest line item a tenant has, and the admin center makes it surprisingly awkward to answer "who has what?" across every SKU at once.
Get-M365LicenseReport -OutputPath .\reports
It connects to Microsoft Graph with just Organization.Read.All and User.Read.All, then produces two CSVs:
- A SKU summary: enabled, consumed and available units for each subscribed SKU, so you can spot both shelfware and SKUs that are about to run out.
- Per-user assignments: one row per user per assigned SKU, with the SKU part number resolved from its GUID.
What I look for:
- SKUs with a large gap between purchased and consumed (money on the table at renewal).
- Disabled accounts still holding paid licenses. Sort the per-user CSV by
AccountEnabledand this jumps out immediately. - Users with overlapping SKUs (for example a standalone add-on that's already included in a suite they have).
2. Stale accounts: Get-StaleEntraUsers
Every tenant collects dead accounts: people who left, test accounts, "temporary" vendor logins. Each one is an attack surface and often a license too.
Get-StaleEntraUsers -DaysInactive 90 -IncludeNeverSignedIn
This uses the signInActivity property on the user object, which carries both the last interactive and last non-interactive sign-in. The script treats a user as stale only when both are older than the threshold. That matters: a mailbox used only by a sync client or a service can look dormant if you only check interactive sign-ins.
A few practical notes:
-
signInActivityneeds Entra ID P1 or P2 and theAuditLog.Read.Allscope in addition toUser.Read.All. - By default only Member accounts are evaluated. Add
-IncludeGueststo include B2B guests, which is often where the real clutter is. -
-IncludeNeverSignedInadds accounts with no recorded sign-in that were created before the cutoff. Those are frequently provisioning leftovers.
I hand the CSV to whoever owns access reviews rather than disabling accounts from the script. Keeping the report and the action separate means nobody gets locked out by a bad assumption.
3. External forwarding: Get-MailboxForwardingAudit
This one has caught real problems for me. Auto-forwarding to an outside address is a classic sign of a compromised mailbox, and it's also how well-meaning users leak data to personal accounts.
Get-MailboxForwardingAudit -InternalDomains contoso.com, contoso.onmicrosoft.com
It connects to Exchange Online and checks two places forwarding can hide (use -SkipInboxRules for a faster mailbox-only pass on large tenants):
-
Mailbox-level forwarding (
ForwardingAddress/ForwardingSmtpAddress), which admins or users can set. - Inbox rules that forward, redirect, or forward-as-attachment, which is where attackers prefer to put it, since users rarely look.
Anything that targets a domain not in -InternalDomains gets flagged. Pass every accepted domain you own, or you'll get noise from internal forwards.
When it finds something, I check the account's sign-in logs before touching the rule. If the forwarding was set by an attacker, the evidence matters more than a quick cleanup. Separately, it's worth confirming that your outbound spam policy blocks automatic external forwarding by default and only allows it by exception.
Why read-only first
None of these scripts will fix anything for you, and that's on purpose. Reports are safe to schedule, safe to hand to a junior admin, and safe to run in a tenant you don't fully understand yet. They turn "I think we have a lot of stale accounts" into a CSV you can put in front of the people who decide.
Run them on a regular cadence and compare each run against the last one. The changes are often more interesting than the totals.
The code is MIT-licensed. Issues and pull requests are welcome:
Top comments (1)
tr.ee/dev-to