Monitoring with PowerShell: O365 location alerts

A while back someone asked me to convert a script they had to the Secure Application Model. This specific script checked the Office 365 audit log IPs against a online database of locations. I declined at first and suggested it to look into Microsoft 365, a P1 or P2 subscription which allows you to do this native.

The reader came back to me recently asking once more, and giving a explanation on why P1/P2 or M365 was not possible in her case. I understand that sometimes you might have to make due with what you have. I also figured others might be in the same boat.

So this script is made to monitor the O365 unified audit log and to compare the IP addresses to a database online. The database she used previously was very rate-limited and as such not really suitable. Not a lot of people know that there actually is a great 100% free online lookup service for IPs at The script uses that database as there are no API limitations. 🙂

As always, I’d like to note that this should just be one layer in your entire security model and you should not put all your faith in this. Enable MFA, and keep good security hygiene.

The Script

You’ll need the Secure Application Model for this script. The script currently checks all your tenants except the ones you state in “$Skiplist”.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'VeryLongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeToken'
$UPN = "UPN-User-To-Generate-IDs"
######### Secrets #########

$AllowedCountries = @('Belgium', 'Netherlands', 'Germany', 'United Kingdom')
$Skiplist = @("", "", "")

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes '' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes '' -ServicePrincipal -Tenant $tenantID 
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken

$customers = Get-MsolPartnerContract -All | Where-Object { $_.DefaultDomainName -notin $SkipList }

$StrangeLocations = foreach ($customer in $customers) {
    Write-Host "Getting logon location details for $($customer.Name)" -ForegroundColor Green
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes '' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    $null = Import-PSSession $session -CommandName Search-UnifiedAuditLog -AllowClobber

    $startDate = (Get-Date).AddDays(-1)
    $endDate = (Get-Date)
    Write-Host "Retrieving logs for $($" -ForegroundColor Blue
    $logs = do {
        $log = Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $ -ResultSize 5000 -StartDate $startDate -EndDate $endDate -Operations UserLoggedIn
        Write-Host "Retrieved $($log.count) logs" -ForegroundColor Yellow
    } while ($Log.count % 5000 -eq 0 -and $log.count -ne 0)
    Write-Host "Finished Retrieving logs" -ForegroundColor Green
    $userIds = $logs.userIds | Sort-Object -Unique

    $LocationMonitoring = foreach ($userId in $userIds) {
        $searchResult = ($logs | Where-Object { $_.userIds -contains $userId }).auditdata | ConvertFrom-Json -ErrorAction SilentlyContinue
        $ips = $searchResult.clientip | Sort-Object -Unique
        foreach ($ip in $ips) {
            $IsIp = ($ip -as [ipaddress]) -as [bool]
            if ($IsIp) { $ipresult = (Invoke-restmethod -method get -uri "$($ip)") -split ';' }
                user              = $userId
                IP                = $ip
                Country           = ($ipresult | Select-Object -index 3)
                CountryCode       = ($ipresult | Select-Object -Index 1)
                Company           = $customer.Name
                TenantID          = $customer.tenantID
                DefaultDomainName = $customer.DefaultDomainName

    foreach ($Location in $LocationMonitoring) {
        if ($ -notin $AllowedCountries) { $Location }
if (!$StrangeLocations) {
    $StrangeLocations = 'Healthy'


And that’s it! Now you’re monitoring the locations from where users are logging on. As always, Happy PowerShelling!


  1. Zachery Frazier July 27, 2020 at 6:14 pm

    Great script, I’ve used something similar from GCITS before. I just tried this script but i’m getting an error on the invoke web request. It appears it returns WRONG INPUT. Went to the website and tested the request and it dumps out right. Currently looking through and see if I deleted something i shouldn’t have in the script.

    Love your stuff btw!

    1. VMarinaro July 29, 2020 at 6:25 pm

      I’m seeing the same “WRONG INPUT” error, which seems to happen if you pass an IPv6 address from the audit log to the API – it can only handle IPv4 addresses.

      I fixed it by adding the following line before the invoke-restmethod call to ip2c:

      if ($ip.length -gt 15) {continue}

      Also, I love your stuff as well!

      1. Zachery Frazier July 29, 2020 at 9:27 pm

        This is awesome and it fixed me all up. I didn’t realize those logs were pulling both IPv4 & IPv6. I wonder if I can implement my to get IPv6 also. Thanks again!

  2. VMarinaro October 20, 2020 at 7:15 pm

    After further testing of this script, I noticed I was getting a lot of false positives. It would return “UserLoggedIn” events for user’s that do not exist in the tenant. Upon further inspection, the audit data for these events contained the field “LogonError” with a value of “UserAccountNotFound”. So for some reason, the audit log returns events for logons from non-existent users and was messing up my results. To resolve this, I decided to filter out any audit events where a LogonError value existed by modifying this line:

    $searchResult = ($logs | Where-Object { $_.userIds -contains $userId }).auditdata | ConvertFrom-Json | Where-Object {$_.LogonError -eq $null} -ErrorAction SilentlyContinue

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.