Introduction
Microsoft Entra ID (formerly Azure AD) sign-in logs are a goldmine for troubleshooting, auditing, and security investigations. Sometimes, you don’t want all the data – you want to focus on a subset of sign-ins that match specific criteria.
In my case, I needed:
- Sign-ins from specific apps (by App ID or name)
- Unmanaged devices (
IsManaged = false) - Devices running Windows 10
- Data from the last 30 days
And of course, I wanted it exported to CSV for further analysis in Excel or Power BI.
Here’s the solution I built – you can grab the full script from GitHub – and below, I break down the most important parts so you can adapt it to your environment.
Prerequisites
Before running the script, you’ll need:
- Microsoft Graph PowerShell SDK v2
- AuditLog.Read.All permissions in Microsoft Graph
- PowerShell 5.1 or newer (script works on Windows PowerShell & PowerShell 7+)
Use Cases
- Conditional Access validation – Verify unmanaged devices are blocked or flagged
- Shadow IT detection – Identify personal devices accessing corporate apps
- Incident response – Focus investigations on risky app/device combos
- Geographic anomaly detection – Spot unusual login locations
- Compliance auditing – Provide auditors with exact unmanaged access attempts
- User awareness – Identify and train users with risky sign-in patterns
How It Works
Parameters (IDs are fastest)
|
1 2 3 4 5 6 |
param( [string[]] $AppIds, [string[]] $AppNames = @(), [int] $DaysBack = 30, [string] $OutCsv = ".\SignInLogs_Win10_Unmanaged.csv" ) |
- AppIds → fastest filter, avoids display name typos
- AppNames → slower, but works if IDs are unknown
- DaysBack → last N days to query
- OutCsv → output file path
Connect to Graph
|
1 2 3 |
if (-not (Get-MgContext)) { Connect-MgGraph -Scopes "AuditLog.Read.All" | Out-Null } |
- Uses
AuditLog.Read.All– admin consent required - Reconnects only if you’re not already signed in
Time window
|
1 2 |
$end = (Get-Date).ToUniversalTime().ToString("o") $start = (Get-Date).AddDays(-$DaysBack).ToUniversalTime().ToString("o") |
- Always uses UTC
"o"format = ISO 8601
App filter (OR groups)
|
1 2 3 |
$appClauses = @() if ($AppIds) { $appClauses += '(' + (($AppIds | % { "appId eq '$_'" }) -join ' or ') + ')' } if ($AppNames) { $appClauses += '(' + (($AppNames | % { "appDisplayName eq '" + ($_ -replace "'","''") + "'" }) -join ' or ') + ')' }a |
- Supports both IDs and display names
- Escapes single quotes in names to avoid OData errors
Device & OS filter
|
1 2 |
$managed = "deviceDetail/isManaged eq false" $osFilter = "deviceDetail/operatingSystem eq 'Windows10'" |
- Exact match to Windows10
- Can extend to
startswith()for variants
Final OData filter
|
1 2 3 4 5 6 7 |
$filter = @( "createdDateTime ge $start" "createdDateTime le $end" $managed $osFilter '(' + ($appClauses -join ' or ') + ')' ) -join ' and ' |
- Combines all conditions into one Graph query
Explicit $select
|
1 2 3 4 5 6 |
$select = @( "id","createdDateTime","userDisplayName","userPrincipalName","userId", "appDisplayName","appId","resourceDisplayName","ipAddress","clientAppUsed", "conditionalAccessStatus","riskDetail","riskLevelAggregated","riskLevelDuringSignIn", "status","deviceDetail","location","correlationId" ) -join "," |
- Ensures Graph returns required fields
- Requests nested objects
REST call + paging
|
1 2 3 4 5 6 |
$uri = "$base`?`$filter=$([uri]::EscapeDataString($filter))&`$select=$select&`$top=999" while ($uri) { $resp = Invoke-MgGraphRequest -Method GET -Uri $uri $all += $resp.value $uri = $resp.'@odata.nextLink' } |
- Avoids submodule loading quirks
- Handles >999 rows with paging
Flatten nested objects
|
1 2 3 4 5 6 7 8 9 10 |
$rows = $all | ForEach-Object { [pscustomobject]@{ createdDateTime = $_.createdDateTime OperatingSystem = $_.deviceDetail.operatingSystem Browser = $_.deviceDetail.browser City = $_.location.city ErrorCode = $_.status.errorCode # ... } } |
- Converts nested objects to flat CSV columns
Export
|
1 |
$rows | Export-Csv -Path $OutCsv -NoTypeInformation -Encoding UTF8 |
- UTF-8 output for compatibility
Summary
Filtering sign-in logs directly in Microsoft Graph makes investigations faster and reporting cleaner. This approach:
- Targets unmanaged Windows 10 devices
- Filters by specific applications
- Flattens nested JSON into CSV
- Handles paging automatically
Whether for incident response, compliance, or policy validation, this is a repeatable and efficient method to get exactly the sign-in data you need.
Script Source
The complete script is available on the Azure365Addict GitHub.
Feel free to download, customize it to your specific needs, and improve your mailbox management processes.
If you have any questions or want to extend this into automated cleanup or reporting, feel free to reach out.
Happy scripting!

