List enabled Microsoft 365 accounts that haven't signed in for a set number of days
Build a read-only workflow action that lists enabled Microsoft 365 accounts that haven't signed in for a set number of days, with their licences, so MSPs and IT teams can find stale accounts and wasted licences.
| Runs in | Cloud sandbox (PowerShell) |
| Uses | Read-only Microsoft Graph credential for reports |
| Suggested approval | Engineers and admins, no approval needed (read-only) |
| Category | Reporting |
Test it with SuperIT before you rely on it
This is an example, not a ready-made action, and its script hasn't been tested end to end in a SuperIT tenant. Reading sign-in activity through a delegated credential hasn't been tested with SuperIT either. Before you use it, work through it with SuperIT: ask SuperIT to review the script against your environment and requirements, adjust it together, then test it on a test account or device. Test in the action editor runs the script for real.
Why use an action instead of asking SuperIT
SuperIT can't answer "who hasn't signed in for 90 days?": sign-in activity isn't readable through the Microsoft 365 connection. This action reads it with its own Microsoft Graph credential, which has the audit-log permission. Scheduled, it can run a stale-account review every quarter, but it must email or post its own results (see Variations).
To check which accounts are enabled or which licences they hold, just ask SuperIT in chat.
When to use it
- "Which accounts haven't been used in 90 days?"
- "Find licensed users who haven't signed in since the summer."
- "Run a stale account review for this client every quarter."
What the action does
- Checks the
daysinput (default 90). - Reads every enabled member account with its last successful sign-in. Failed sign-in attempts don't count, so an unused account that attackers keep trying to sign in to still appears.
- Lists accounts whose last successful sign-in is older than the cut-off, and accounts with no successful sign-in recorded that were created before the cut-off. It leaves out accounts on the script's
$excludedUpnslist. - If
licensed_onlyis on, keeps only accounts that hold a licence. - Prints the list, oldest first, and returns it as structured output.
Not every inactive account should be blocked
The list will include accounts that are inactive by design: emergency access (break-glass) accounts, shared and resource mailboxes, and service accounts. Blocking a break-glass account can lock you out of the tenant in an emergency. Add those accounts to $excludedUpns, and review every account on the list with its owner before you block or remove anything.
What it doesn't do: it doesn't block, delete or unlicense anything; use Block or unblock sign-in or Offboard a Microsoft 365 user after review. It skips guest accounts, and successful sign-ins before Microsoft started recording them (December 2023) are missing, so those accounts show "none recorded".
Before you begin
- The tenant needs Microsoft Entra ID P1 or P2 (included in Microsoft 365 Business Premium, E3 and E5). Without it, Microsoft refuses the sign-in activity request.
- A read-only Microsoft Graph credential for reports, named
M365 Reports, with the scopesAuditLog.Read.All User.Read.All. The admin account that signed in needs Reports Reader, Security Reader or Global Reader. Keep write scopes off this credential, and link it only to read-only actions. The script reads it asCREDENTIAL_M365_REPORTS_TOKEN. See Automate Microsoft 365 with workflows. - These scopes and roles come from Microsoft's documentation and haven't been tested with SuperIT. If the run fails with
Authorization_RequestDeniedor403, see If something goes wrong. - Your excluded accounts. Edit
$excludedUpnsat the top of the script to list your break-glass accounts and any service accounts. - An authorization policy with one rule: Specific groups
superit:engineers,superit:admins→ No approval needed. No policy also works, because then only engineers and admins can run it. Don't add an Anyone rule: end users have no reason to run a tenant-wide sign-in report. - The Engineer or Admin role to build the action in Workflows → Actions. Only admins can create actions in chat.
Build it with SuperIT
Create a workflow action called "Find inactive Microsoft 365 accounts".
Runtime: PowerShell in the cloud sandbox. Credential: M365 Reports. Read-only.
Inputs:
- days (string, required, default 90)
- licensed_only (boolean, optional)
It should read enabled member users with signInActivity, use
lastSuccessfulSignInDateTime (failed attempts don't count), list accounts older than the
cut-off (and accounts with no successful sign-in created before it), oldest first, with licence count.
Leave out accounts on an excluded list in the script.
Authorization policy: engineers and admins, no approval needed. No Anyone rule.
SuperIT repeats the brief back, drafts the script, checks it, and creates the action as a draft. Review the script in Workflows → Actions, test it, then publish it.
Or build it yourself
| Name | Type | Required | Example |
|---|---|---|---|
days |
string | Yes | 90 |
licensed_only |
boolean | No | on |
# Goal: read every enabled member account's sign-in activity and list those with no sign-in
# for `days` days (oldest first), returning the list in $result. Changes nothing.
$token = $env:CREDENTIAL_M365_REPORTS_TOKEN
$headers = @{ Authorization = "Bearer $token"; ConsistencyLevel = 'eventual' }
$baseUrl = 'https://graph.microsoft.com/v1.0'
# Break-glass and service accounts are inactive by design. Leaving them off the list stops
# anyone acting on it from blocking them. List yours.
$excludedUpns = @('[email protected]', '[email protected]')
function Invoke-InactiveAccountReport {
param([string]$Days, [string]$LicensedOnly)
Assert-Inputs -Days $Days
$dayCount = [int]$Days
$cutoff = (Get-Date).ToUniversalTime().AddDays(-$dayCount)
$users = @(Get-EnabledMembers)
$inactive = @(Select-InactiveAccounts -Users $users -Cutoff $cutoff -LicensedOnly ($LicensedOnly -eq 'true'))
Write-InactiveSummary -Days $dayCount -Cutoff $cutoff -Accounts $inactive
}
function Assert-Inputs {
param([string]$Days)
if (-not $token) { throw "Link the M365 Reports credential to this action." }
$n = 0
if (-not [int]::TryParse($Days, [ref]$n) -or $n -lt 1 -or $n -gt 3650) {
throw "days must be a whole number between 1 and 3650."
}
}
function Get-EnabledMembers {
$select = 'displayName,userPrincipalName,createdDateTime,assignedLicenses,signInActivity'
$uri = "$baseUrl/users?`$filter=accountEnabled eq true and userType eq 'Member'&`$count=true&`$select=$select&`$top=100"
try {
while ($uri) {
$page = Invoke-RestMethod -Uri $uri -Headers $headers
$page.value
$uri = $page.'@odata.nextLink'
}
} catch {
Write-Error "Failed to read sign-in activity for enabled members: $_"
exit 1
}
}
function Select-InactiveAccounts {
param([object[]]$Users, [datetime]$Cutoff, [bool]$LicensedOnly)
$rows = foreach ($u in $Users) {
if ($excludedUpns -contains $u.userPrincipalName) { continue }
if ($LicensedOnly -and $u.assignedLicenses.Count -eq 0) { continue }
$last = Get-LastSignIn -User $u
# An account with no successful sign-in only counts once it is older than the cut-off.
$isStale = if ($last) { $last -lt $Cutoff } else { ([datetime]$u.createdDateTime).ToUniversalTime() -lt $Cutoff }
if ($isStale) {
[pscustomobject]@{
User = $u.userPrincipalName
Name = $u.displayName
LastSignIn = if ($last) { $last.ToString('yyyy-MM-dd') } else { 'none recorded' }
Licences = $u.assignedLicenses.Count
}
}
}
$rows | Sort-Object { if ($_.LastSignIn -eq 'none recorded') { '0000' } else { $_.LastSignIn } }
}
function Get-LastSignIn {
param([object]$User)
# Not lastSignInDateTime: it includes failed attempts, which would hide an unused account under attack.
# Microsoft only records this from December 2023, so older activity reads as none recorded.
$value = $User.signInActivity.lastSuccessfulSignInDateTime
if ($value) { ([datetime]$value).ToUniversalTime() }
}
function Write-InactiveSummary {
param([int]$Days, [datetime]$Cutoff, [object[]]$Accounts)
Write-Output "=== INACTIVE ACCOUNTS ==="
Write-Output "Days : $Days"
Write-Output "Cutoff : $($Cutoff.ToString('yyyy-MM-dd'))"
Write-Output "Count : $($Accounts.Count)"
foreach ($a in $Accounts) {
Write-Output "$($a.User) | $($a.Name) | last sign-in $($a.LastSignIn) | licences $($a.Licences)"
}
# $script: so the runner's wrapper sees it; a plain assignment stays local to this function.
$script:result = @{ days = $Days; cutoff = $Cutoff.ToString('yyyy-MM-dd'); accounts = $Accounts }
}
Invoke-InactiveAccountReport -Days $ctx.inputs.days -LicensedOnly $ctx.inputs.licensed_only
Test it
Test runs for real
Test skips authorization and approval and uses the real credential. This action only reads, so it changes nothing, but it does read real sign-in data.
The script is untested, so treat your first test runs as debugging, and don't publish the action until a test run does exactly what you expect.
- On the action, select Test. Enter
days=30. - Read the This is not a dry run. warning, tick "I understand this is a real execution that may change real systems." and select Start test run.
- Check Stdout. Spot-check a couple of accounts against Sign-in logs in the Microsoft Entra admin center.
If the run fails with a message about a premium licence, the tenant has no Entra ID P1 or P2.
Use it
Publish the action. Engineers ask SuperIT, for example "Find licensed accounts that haven't signed in for 60 days". Review the list with the customer or the account owners before acting on it; service accounts and shared mailboxes often show as inactive by design.
To run it regularly, an engineer or admin asks SuperIT, for example "Run the inactive accounts check every 90 days". Schedules repeat every set number of days. A scheduled run's output stays in the action's run history and isn't sent to anyone, so add a delivery step (see Variations).
For MSPs, publish to managed teams from the Publish dialog; an engineer or admin in each team authorizes its own credential.
Variations
- Include guests by dropping the
userTypefilter, and report them separately. - Hand the list to Block or unblock sign-in one account at a time, after the account's owner confirms it isn't a break-glass, shared, resource or service account. That action refuses admins and its own protected list, but check anyway.
- Post the list when it runs on a schedule, with a webhook post like Post a message to a Teams or Slack channel. Actions can't call each other, so the delivery step goes in this script. To email it instead, don't add
Mail.SendtoM365 Reports: build it like Send a weekly summary report, with that guide's separate mail credential and recipient checks.