Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

Three read-only PowerShell reports every Microsoft 365 admin should run

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

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:

  1. Where are the licenses going?
  2. Which accounts nobody is using anymore?
  3. 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 AccountEnabled and 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:

  • signInActivity needs Entra ID P1 or P2 and the AuditLog.Read.All scope in addition to User.Read.All.
  • By default only Member accounts are evaluated. Add -IncludeGuests to include B2B guests, which is often where the real clutter is.
  • -IncludeNeverSignedIn adds 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:

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.