Cloudek.

microsoft-365

Automating Microsoft 365 License Cost Audits via Read-Only Graph API Tokens

A careful, read-only workflow for finding Microsoft 365 license-review candidates without confusing inactivity signals with proof that a seat can be reclaimed.

A licensing audit should produce review candidates, not a list of users to strip automatically. The distinction matters: Microsoft Graph can show assigned license data, but assignment is not the same thing as actual usage, contract cost, or permission to reclaim a seat. A read-only report is useful when it makes the evidence and its gaps visible.

A bearer token itself is not a read-only control. The Graph permissions and application grants, endpoint, and code that handles the response determine what an app can do. This guide illustrates read operations only.

Start with one question: which users or services deserve a human licensing review? Define the population before collecting anything. Exclude or separately label guests, shared mailboxes, emergency accounts, service identities, seasonal workers, and accounts governed by a group policy. Confirm the correct tenant and the licensed workload. A query that silently stops after one page describes a sample, not the whole directory.

For a deliberately small user sample, request only the fields needed to inspect a designated premium SKU assignment. Replace the placeholder with a SKU identifier approved by the tenant operator. This PowerShell example is a read-only illustration; it is not run by this post, it requests only one page, and it does not remove or change assignments:

$skuId = '<designated-SKU-GUID>'
$users = Get-MgUser -Top 100 -Property 'id,displayName,userPrincipalName,assignedLicenses'
$users |
  Where-Object { $_.AssignedLicenses.SkuId -contains $skuId } |
  Select-Object -First 100 DisplayName, UserPrincipalName

The result is bounded by the first 100 users returned before filtering. It cannot establish how many matching users exist beyond that page. A production report should mark the cap, preserve provider continuation as an incomplete-coverage signal, and avoid presenting an empty or short result as proof that no candidates exist. Use only the specific read permissions required for selected properties, grant consent through the tenant’s normal process, and do not reuse a write permission merely because a different endpoint supports changing licenses.

Three kinds of evidence are easy to confuse. assignedLicenses shows what is assigned to a user. subscribedSkus.consumedUnits counts assigned units for a subscribed SKU, while prepaidUnits describes unit states such as enabled, warning, suspended, and locked out. A service-usage report describes activity in a workload over its reporting period. None alone is an invoice or a complete answer to “can we reduce this bill?” Verify the applicable subscription terms, billing cadence, seat commitments, and SKU mapping with the licensing owner before estimating any savings.

Ninety days with no recorded activity can be a sensible review threshold, but it is only a candidate signal. A person may use a workload infrequently, access it through a different identity, be on leave, need archival or legal retention, or have activity absent from the selected report. A null usage date is unknown—not “unused.” The Graph sign-in property lastSuccessfulSignInDateTime is different from the last sign-in attempt: it records successful authentication across interactive and non-interactive sign-ins. Microsoft says the property’s data is not backfilled, and Graph requires Entra ID P1 or P2 plus AuditLog.Read.All to retrieve sign-in activity. Missing history therefore cannot prove that an account never had successful access.

A safer workflow has five gates. First, agree on the customer, tenant, scope, lookback window, and report freshness. Second, fetch only selected fields and enforce a hard page and byte cap. Third, aggregate locally where possible; redact identities from summary output. Fourth, present each candidate with the evidence source, observation time, retention caveat, and an explicit “review needed” label. Fifth, let an authorized licensing owner verify business need and pricing, then make any change separately with its own exact preview and approval. Do not allow model text to become a Set-MgUserLicense call.

The report should include counts, not pretend to calculate dollars when it has no contract price. It should explain whether the sample is partial, what was excluded, and which source was actually read. If an endpoint fails, say “unavailable”; do not convert a permissions error into zero users. If a user count is capped, state that more rows may exist. If a timestamp is stale, show its age. These small details prevent a neat table from becoming a misleading recommendation.

A practical review queue can record a candidate reason, selected source and period, reviewer, decision, and date outside the raw Graph result. Keep the raw identifiers in the system authorized to hold them, not in public examples or general AI prompts. Follow least-privilege guidance and validate the exact Graph permissions against the endpoint documentation before any tenant connection.

Sources: Graph user list and signInActivity prerequisites, signInActivity semantics, license assignment state, and subscribed SKU units. Product availability and tenant configuration should be verified independently; this article does not claim a connected customer report or a savings result.

The useful outcome is a smaller, evidence-backed review queue—not an automated licensing decision. Keep scope and source limits attached when sharing. Treat cleanup as a question for the owner, not a command. This keeps cost governance auditable, reversible, and respectful of business context.