To find inactive Microsoft 365 users, join two things Microsoft keeps in separate places: the licences assigned to each account, and the date that account last signed in successfully. Microsoft Graph holds both on the user object, so a single Get-MgUser call that selects assignedLicenses and signInActivity gives you one row per licensed account with its last sign-in date. Age those rows against a cutoff your team agrees in advance, usually 30, 60 or 90 days, and every account above the line is a paid seat with no evidence of use behind it. The script below does exactly that and writes a CSV you can take into a review. Copy it, run it, keep it.

This is the sign-in side of licence waste. The companion guide on how to find unused Microsoft 365 licences covers the other side, the gap between what you have bought and what you have assigned. That gap is the easy one. This one, the seat that is assigned to a real account that nobody is using, is usually the bigger number and the harder conversation.

Start with the arithmetic, because it decides whether this is worth an afternoon

Do the sum first, with your own numbers rather than anybody's benchmark. On a 300-user estate, if 4 per cent of licensed accounts have not signed in for a quarter, that is 12 seats. Twelve seats at whatever you actually pay per user per month, times twelve months, is the annual figure. At £20 a seat that is £2,880 a year; at £30 it is £4,320. Change any input and the answer moves, which is the point: you can check this in your head and so can your finance lead.

Two things make that worth chasing in a 50 to 600-user organisation. It recurs every month until somebody acts, so doing nothing compounds. And it is almost never anybody's job. Finance sees a stable bill and assumes it is correct; IT sees a licence count and assumes finance approved it. That is how dormant seats survive for years.

Why the Licenses page cannot answer this

The obvious first stop is the Microsoft 365 admin centre, under Billing then Licenses, which shows each product with how many seats you have bought and how many are assigned. That is a useful two-minute check and you should run it. Note that it aggregates per product, while Billing then Your products lists a row per subscription, so the two pages will not always agree at a glance.

What neither will tell you is whether an assigned seat is doing anything. Those pages are built around entitlement, not behaviour. An account created for a contractor two years ago, still enabled, still holding a full seat, and not signed into since, looks identical there to your busiest user.

Reports then Usage gets you closer. The Active users report shows a last active date per workload for Exchange, OneDrive, SharePoint and Teams, along with the licences assigned. It is worth knowing, and it answers a different question: whether someone touched an app, not whether the identity authenticated at all. Two things to expect. Data is generally available over the last 7, 30, 90 and 180 days and can take a day or more to appear. And user names are concealed by default, so if the report shows anonymised rows an administrator has to clear the "Conceal user, group, and site names in all reports" option under Settings, Org Settings, Services, Reports.

Agree what inactive means before you run anything

This is the step teams skip, and it decides whether your report survives contact with the rest of the business. Thirty days is aggressive and sweeps up parental leave, long-term sick, sabbaticals and seasonal staff. Ninety days lets a full quarter of waste run before anything is flagged. Sixty is a reasonable first pass in a mid-sized estate. There is no correct answer, and anyone who tells you there is, vendors included, is selling you their default.

What matters is that the cutoff is a written policy your team owns, with named exceptions, before the first report goes to anyone. A report without an agreed threshold produces an argument. A report with one produces actions.

The sign-in data behind the answer

Microsoft Entra ID records sign-in activity against each user object, and Graph exposes it on the v1.0 user resource as the signInActivity property. It carries a last interactive sign-in, a last non-interactive sign-in, and a last successful sign-in, each paired with a request ID. Interactive means a person typed credentials or approved a prompt. Non-interactive covers a client refreshing a token in the background, which is why an account can look busy in one field and dead in another.

The reason to use this rather than trawling the sign-in logs is retention. Sign-in and audit logs are kept for 7 days on the free tier and 30 days with Entra ID P1 or P2, so the log alone cannot tell you about a 90-day gap. The signInActivity property is different: Entra stores it for as long as the user object exists. It is the right source for this question.

Four practical constraints before you run anything:

  • It is only returned when you explicitly select it. A plain Get-MgUser will not include it, and the property is simply absent rather than throwing an error, which is the most common reason people conclude the data is missing.
  • Reading it requires AuditLog.Read.All alongside User.Read.All. Without the audit scope the licence data returns and the sign-in field comes back empty, which looks like every account is inactive.
  • It requires Microsoft Entra ID P1 or P2. On a tenant without one, expect the property to be unavailable, so check that before debugging a null column.
  • A blank value is not proof of disuse. Last successful sign-in has only been recorded since December 2023 and was not backfilled, and the value can lag by up to 24 hours. Treat blank as "investigate", never as "delete".

On the directory side you do not need Global Administrator. Reports Reader is the least privileged role that can read audit and sign-in logs, and it is the right one to use: this is a reporting task and should not be a reason for anyone to hold a write role.

The script

This is the whole thing. It connects, builds a lookup so the output reads in plan names rather than GUIDs, pulls every licensed account with its sign-in activity, ages it against your cutoff, and writes a CSV.

# Inactive licensed users report
# Requires the Microsoft.Graph PowerShell SDK
Connect-MgGraph -Scopes "User.Read.All","AuditLog.Read.All","Organization.Read.All"
$InactiveDays = 60
$Cutoff = (Get-Date).AddDays(-$InactiveDays)
# Map SKU GUIDs to readable plan names
$SkuMap = @{}
Get-MgSubscribedSku | ForEach-Object { $SkuMap[$_.SkuId] = $_.SkuPartNumber }
# signInActivity is only returned when explicitly selected
$Users = Get-MgUser -All -Property "id,displayName,userPrincipalName,accountEnabled,userType,createdDateTime,assignedLicenses,signInActivity"
$Report = foreach ($u in $Users) {
    if (-not $u.AssignedLicenses -or $u.AssignedLicenses.Count -eq 0) { continue }
    if ($u.UserType -eq 'Guest') { continue }
    $last = $u.SignInActivity.LastSuccessfulSignInDateTime
    if (-not $last) { $last = $u.SignInActivity.LastSignInDateTime }
    if ($last -and $last -ge $Cutoff) { continue }
    [pscustomobject]@{
        DisplayName       = $u.DisplayName
        UserPrincipalName = $u.UserPrincipalName
        Enabled           = $u.AccountEnabled
        Created           = $u.CreatedDateTime
        LastSignIn        = $last
        DaysSinceSignIn   = if ($last) { [int](New-TimeSpan -Start $last -End (Get-Date)).TotalDays } else { 'Never' }
        Licences          = (($u.AssignedLicenses.SkuId | ForEach-Object { $SkuMap[$_] }) -join '; ')
    }
}
$Report | Sort-Object Created |
    Export-Csv -Path ".\inactive-licensed-users.csv" -NoTypeInformation -Encoding UTF8
"{0} licensed accounts with no sign-in in {1} days" -f @($Report).Count, $InactiveDays

Read it once before you run it. It skips unlicensed accounts, because a dormant unlicensed account costs nothing, and it skips guests, because they follow different rules and would otherwise dominate the output. It prefers the last successful sign-in and falls back to the last interactive sign-in, so it behaves sensibly on accounts whose successful sign-in predates the December 2023 start of that field. It ages the rows in PowerShell rather than in the Graph query: signInActivity can be filtered server-side, but not in combination with any other filterable property, so doing it client-side keeps the licence and guest conditions in the same place. An account that has never signed in shows as Never rather than a blank, because that is a different finding from a long-dormant one.

The loop in four moves. The script covers the first two. The last two are judgement, and they are where the money actually moves.

What the report usually turns up, and the decision each one needs

Almost every first run produces the same four categories, and each needs a different decision from a different person.

Leavers who were never fully offboarded. An account still enabled, still licensed, sometimes months after the person left. This is the highest-value and lowest-risk finding, and it is a process failure rather than a licensing one. Remove the licence, block sign-in, and deal with the mailbox and files properly. The Microsoft 365 offboarding checklist has the full order of operations, which matters because doing these steps in the wrong sequence loses data.

Service and shared accounts holding full seats. These often show no interactive sign-in at all while genuinely being in use by an application. Do not touch them until you have found the owner. Ask what the account actually needs, because a full productivity seat is frequently far more than the answer.

Duplicate or overlapping add-ons. Accounts carrying an add-on that a later base plan already includes. The account is active, so it never appears as dormant, but the add-on is dead weight. Sorting your output by the licence column surfaces these quickly.

Genuinely idle real people. Someone on long leave, someone who changed role, someone onboarded onto a plan they never used. This is the category that needs a manager in the conversation rather than a script, and the outcome is often a downgrade rather than a removal. Our guide to Business Premium, E3 and E5 is a useful reference for what a lighter plan actually costs the user.

One warning before you act on any of it. Check where each licence came from before you strip it off the user. Group-based assignments are managed at the group, and the Licenses page shows the group name rather than its members, so a seat you remove from an account can quietly come back through group membership and the saving never reaches the invoice.

If the four categories above already look familiar, you can see the same report running continuously against your own tenant on a 14-day EtherInsights trial, no script maintenance required.

The honest limit of running this as a script

The script is good. It is also a snapshot, and that is its ceiling. Someone has to remember to run it. Someone has to interpret it, because a CSV of 40 accounts is not a decision, it is 40 decisions. Someone has to chase the owners, apply the changes, and then prove to finance the seat count actually fell at renewal, which is a separate job again. Miss two months and the estate has moved underneath you.

That is not an argument against scripting it. Run it this week regardless. It is an argument for being clear-eyed about month four, when the person who wrote it is busy and the report quietly stops being run. In a 50 to 600-user organisation the constraint is rarely capability. It is that nobody owns the recurring task.

Where EtherInsights fits

EtherInsights is the continuous version of what this script does once. It reconciles licences against real activity on a schedule, surfaces dormant and never-used seats as findings with a named owner and a next action rather than as a spreadsheet, and tracks the before and after so a reclaim arrives at your finance review as evidence rather than a claim. For the full lifecycle, from a dormant seat being flagged through to a leaver being offboarded before their licence bills again, Microsoft 365 licence management and offboarding is the route that closes the loop.

It is £0.79 per active user with a 14-day trial, so you can run the arithmetic at the top of this article against your own tenant before committing to anything. If you need to take a budget-holder a number rather than a method, that is usually the fastest way to get one. Licence waste is one line in a wider picture, which is why it sits inside cloud cost optimisation, and why proving the saving matters as much as finding it.

Agree the threshold, run the script, act on the leavers immediately, and put a date in the calendar for next month. If that date keeps slipping, you have found the real problem, and it is not a technical one.

Explore Microsoft 365 licence management and offboarding to see dormant seats, leaver offboarding, and the evidence trail handled as one continuous process instead of a monthly reminder.