Fixing the "Application URL" Analytic Rules, All Three of Them

Fixing the "Application URL" Analytic Rules, All Three of Them

Alright class.

Another lesson, first one fixing three rules at once. The content hub ships "Changes to Application Logout URL", "Application Redirect URL Update" and "Application ID URI Changed" as three separate daily rules. All three read AuditLogs with the same ApplicationManagement filter, and two of them fire on the exact same events, because redirect and logout addresses both travel inside the same AppAddress property. This is one detection cut into three noisy pieces, so today they become one scored rule.

The Logout Rule Does Not Detect Logout Changes

The heart of the first rule:

| extend Action = tostring(mod_props.displayName)
| where Action contains "Url"

The rule promises logout URL monitoring, then matches any modified property whose name contains "Url". Update the homepage of an application and the modified property is named "Url", which contains "Url", so the "logout URL changed" alert fires on a homepage edit. The rule name and the rule logic are two different rules.

There is also no Result =~ "success" filter, so an update attempt that failed and changed nothing still raises the alert. And since logout addresses are recorded inside AppAddress too, this rule and the redirect rule regularly wake the analyst twice for one audit event, a day late, because both run daily.

Quick example below, I just changed the logo in the app and it triggered the logout URL

What These Rules Should Actually Catch

Look at the three properties through an attacker's eyes and they are the same target. A redirect URI added to an application means authorisation codes and tokens can be delivered to a location the attacker controls. That is token theft with a configuration change instead of malware. A logout URL removed or blanked means users cannot properly terminate sessions, which quietly extends the life of anything the attacker has stolen. An Application ID URI change repoints the audience of issued tokens, which matters for any API trusting that identifier.

All three are tampering with the authentication surface of one application, usually by one actor in one sitting. One rule owns this axis. Credential and secret additions are a separate axis and belong to their own rule, so this rebuild deliberately leaves them out.

The False Positives Are Developers

The benign process producing this exact telemetry is a developer adding https://localhost:3000 to their own app registration on a Tuesday afternoon. The second is a deployment pipeline updating redirect URIs during a release. The third is an admin wiring up a new SaaS integration whose redirect points at the vendor's domain.

The rebuild handles each one by design. Localhost and 127.0.0.1 are exempt from every redirect signal. Deployment service principals go into a TrustedAutomation allowlist, and only unlisted service principals score. Organisation-owned domains go into TrustedDomains, so routine redirects to your own estate score nothing. The single admin adding one vendor redirect to an actively maintained app scores 5, below the gate, and stays silent on purpose.

Two amplifiers only ever stack. The role check marks initiators holding no application admin role, the application owner path attackers love, and only evaluates when an IdentityInfo row exists. The dormancy check marks apps untouched for the prior 14 days, and only evaluates when the prior window contains data, so a freshly connected AuditLogs feed cannot make every application look dormant.

The Rebuild

The whole decision is one line:

| where RiskScore >= FireThreshold

A wildcard redirect URI scores 9 and fires alone, because there is no honest reason for a wildcard in a redirect. An untrusted redirect scores 5 and needs one more signal, such as a non-admin initiator, a dormant application or a plain http scheme. Logout removal behaves the same way at 5.

Degradation is silent by design. No UEBA means no IdentityInfo, so the role signal never fires and never false alarms. No prior AuditLogs data keeps the dormancy signal quiet. The core signals need nothing beyond AuditLogs itself.

The RiskIndicators Field

Every alert carries a plain trail:

Redirect added outside trusted domains | Initiator holds no application admin role | App untouched for 14 days before this change

That exact combination is the one to chase: it is the shape of a compromised application owner setting up token interception. The alert also carries the added and removed URLs, the old and new logout and URI values, the initiator's job title, department and roles, the user agent and the IP, so the first ten minutes of investigation are already on the screen.

The Blind Spots You Should Know About

An admin-credentialed change adding one untrusted redirect to an actively maintained application scores 5 and stays silent. That is the price of not alerting on every SaaS integration, and I accept it. A compromised service principal in TrustedAutomation walks straight past the app-initiated signal, so review that list like the attack surface it is. Homepage and error URL changes are excluded on purpose. And this rule sees the configuration change, not the token theft that follows it, so pair it with your sign-in and consent anomaly detections.

MITRE Mappings for the Updated Rule

T1528, Steal Application Access Token, because a hijacked redirect URI hands the authorisation code, and with it the token, to the attacker. T1098, Account Manipulation, because rewriting the authentication settings of a cloud application object keeps or extends access. Tactics: Credential Access and Persistence.

Rule Settings

Frequency is 1 hour, query period is 14 days. Severity Medium. Event grouping is alert per result. Incident grouping is enabled, matched on the Account entity over 6 hours. Entity mappings: Account (Name, UPNSuffix), IP (Address), CloudApplication (Name), URL (Url, the most suspicious added redirect). Custom details carry the target app id, added and removed redirects, untrusted and wildcard redirects, old and new logout URL and App ID URI, actor roles and department, user agent and operation name.

KQL

// =====================================================================
// Application Authentication Surface Tampering
// =====================================================================
// Description : Scores redirect URI additions, logout URL removals and
//               Application ID URI changes on Entra applications; trusted
//               domains, localhost development entries and allowlisted
//               automation service principals stay below the alert gate.
// Type        : Detection
//
// Tables      : AuditLogs, IdentityInfo
// Connectors  : Microsoft Entra ID (AuditLogs); UEBA for IdentityInfo
// License     : Entra ID P1 for AuditLogs export; UEBA enabled for
//               IdentityInfo, the role signal stays silent without it
//
// Tuning      : - Set the rule query period to P14D; the 14d dormancy and
//                 identity lookups only resolve if the rule looks back that far
//               - TrustedDomains: organisation-owned redirect domains, populate
//                 before enabling or every legitimate redirect scores as untrusted
//               - TrustedAutomation: CI/CD service principal ids allowed to
//                 modify app registrations
//               - DetectionWindow: keep equal to the rule frequency so each
//                 change is scored exactly once
//               - FireThreshold: raise to 9 to require a serious signal or a
//                 three-signal stack
//
// Known FPs   : - Developers adding localhost redirects to their own apps;
//                 localhost and 127.0.0.1 are exempt from redirect signals
//               - Deployment pipelines updating redirect URIs; allowlist the
//                 service principal in TrustedAutomation
//               - New SaaS integrations added by admins; a single untrusted
//                 redirect scores 5 and stays below the gate of 8
//
// Author      : Bartosz Wysocki | https://www.itprofessor.cloud
// Version     : 1.0 | 2026-07-24
// =====================================================================
// --- Tuning variables ---
let DetectionWindow = 1h;                       // must equal the rule frequency so each change is scored exactly once
let HistoryLookback = 14d;                      // how far back the app dormancy baseline reaches
let IdentityLookback = 14d;                     // window for the freshest IdentityInfo snapshot
let TrustedDomains = dynamic(["yourcompany.com", "yourcompany.onmicrosoft.com"]); // organisation-owned redirect domains, populate before enabling
let TrustedAutomation = dynamic([]);            // service principal ids permitted to modify app registrations (CI/CD)
let AppAdminRoles = dynamic(["Global Administrator", "Application Administrator", "Cloud Application Administrator"]); // roles expected to manage applications
// --- Scoring: serious signals clear FireThreshold alone, soft signals only stack ---
let W_WildcardRedirect = 9;                     // wildcard inside an added redirect URI
let W_UntrustedRedirect = 5;                    // redirect added outside TrustedDomains
let W_InsecureRedirect = 3;                     // plain http redirect, localhost exempt
let W_LogoutRemoved = 5;                        // logout URL blanked or removed
let W_AppIdUriChanged = 4;                      // Application ID URI modified
let W_DormantApp = 3;                           // no configuration changes to this app in the prior window
let W_NonAdminActor = 3;                        // initiator holds no application admin role (owner path)
let W_AppInitiated = 3;                         // change made by a service principal outside TrustedAutomation
let FireThreshold = 8;                          // minimum RiskScore required to alert
// --- Successful application configuration changes across the full query period ---
let BaseChanges = materialize(
    AuditLogs
    | where TimeGenerated > ago(HistoryLookback)
    | where Category =~ "ApplicationManagement"
    | where Result =~ "success"
    | where OperationName in~ ("Update Application", "Update Service principal")
    | mv-apply Target = TargetResources on (
        where tostring(Target.type) in~ ("Application", "ServicePrincipal")
        | project TargetAppName = tostring(Target.displayName),
                  TargetObjectId = tostring(Target.id),
                  ModProps = Target.modifiedProperties
    )
    | extend InitiatorUPN = tostring(InitiatedBy.user.userPrincipalName),
             InitiatorId = tostring(InitiatedBy.user.id),
             InitiatorIP = tostring(InitiatedBy.user.ipAddress),
             InitiatorApp = tostring(InitiatedBy.app.displayName),
             InitiatorSPId = tostring(InitiatedBy.app.servicePrincipalId),
             UserAgent = extract(@'"User-Agent"[^}]*"value"\s*:\s*"([^"]+)"', 1, tostring(AdditionalDetails))
);
// --- Dormancy baseline: apps touched before the detection window; scores only when prior data exists ---
let PriorTouchedApps = toscalar(
    BaseChanges
    | where TimeGenerated <= ago(DetectionWindow)
    | summarize make_set(TargetObjectId, 100000)
);
// --- URL and URI properties changed inside the detection window ---
let WindowProps = materialize(
    BaseChanges
    | where TimeGenerated > ago(DetectionWindow)
    | mv-apply Prop = ModProps on (
        where tostring(Prop.displayName) in~ ("AppAddress", "LogoutUrl", "Logout Url", "AppIdentifierUri")
        | project PropName = tostring(Prop.displayName),
                  OldValue = tostring(Prop.oldValue),
                  NewValue = tostring(Prop.newValue)
    )
);
// --- AppAddress: diff old against new, classify added and removed entries by address type ---
let AddressRows = materialize(
    WindowProps
    | where PropName =~ "AppAddress"
    | extend AddedEntries = set_difference(todynamic(NewValue), todynamic(OldValue)),
             RemovedEntries = set_difference(todynamic(OldValue), todynamic(NewValue))
);
let AddressDiff = union
    (AddressRows | mv-expand Entry = AddedEntries | extend Direction = "Added"),
    (AddressRows | mv-expand Entry = RemovedEntries | extend Direction = "Removed")
    | extend Address = tostring(Entry.Address),
             AddressKind = case(tostring(Entry.AddressType) == "0", "Redirect",
                                tostring(Entry.AddressType) == "1", "Homepage",
                                tostring(Entry.AddressType) == "2", "Logout",
                                "Other")
    | where isnotempty(Address)
    | summarize AddedRedirects = make_set_if(Address, Direction == "Added" and AddressKind == "Redirect"),
                RemovedRedirects = make_set_if(Address, Direction == "Removed" and AddressKind == "Redirect"),
                AddedLogoutUrls = make_set_if(Address, Direction == "Added" and AddressKind == "Logout"),
                RemovedLogoutUrls = make_set_if(Address, Direction == "Removed" and AddressKind == "Logout")
            by CorrelationId, TargetObjectId;
// --- LogoutUrl and AppIdentifierUri recorded as flat properties ---
let SimpleChanges = WindowProps
| where PropName !~ "AppAddress"
| summarize OldLogoutUrl = trim(@'[\[\]"\s]+', maxif(OldValue, PropName has "Logout")),
            NewLogoutUrl = trim(@'[\[\]"\s]+', maxif(NewValue, PropName has "Logout")),
            OldAppIdUri = trim(@'[\[\]"\s]+', maxif(OldValue, PropName =~ "AppIdentifierUri")),
            NewAppIdUri = trim(@'[\[\]"\s]+', maxif(NewValue, PropName =~ "AppIdentifierUri"))
        by CorrelationId, TargetObjectId;
// --- One row per change operation with initiator metadata ---
let ChangeMeta = WindowProps
| summarize EndTime = max(TimeGenerated),
            take_any(TargetAppName, InitiatorUPN, InitiatorId, InitiatorIP, InitiatorApp, InitiatorSPId, UserAgent, OperationName)
        by CorrelationId, TargetObjectId;
// --- Latest identity snapshot for the initiating user ---
let ActorIdentity = IdentityInfo
| where TimeGenerated > ago(IdentityLookback)
| where isnotempty(AccountUPN)
| summarize arg_max(TimeGenerated, JobTitle, Department, AssignedRoles) by AccountUPN
| project ActorUPNKey = tolower(AccountUPN), ActorJobTitle = JobTitle, ActorDepartment = Department, ActorRoles = AssignedRoles, IdentityFound = true;
ChangeMeta
| join kind=leftouter AddressDiff on CorrelationId, TargetObjectId
| join kind=leftouter SimpleChanges on CorrelationId, TargetObjectId
| extend ActorUPNKey = tolower(InitiatorUPN)
| join kind=leftouter ActorIdentity on ActorUPNKey
| extend AddedRedirects = coalesce(AddedRedirects, dynamic([])),
         RemovedRedirects = coalesce(RemovedRedirects, dynamic([])),
         AddedLogoutUrls = coalesce(AddedLogoutUrls, dynamic([])),
         RemovedLogoutUrls = coalesce(RemovedLogoutUrls, dynamic([])),
         OldLogoutUrl = coalesce(OldLogoutUrl, ""),
         NewLogoutUrl = coalesce(NewLogoutUrl, ""),
         OldAppIdUri = coalesce(OldAppIdUri, ""),
         NewAppIdUri = coalesce(NewAppIdUri, "")
// --- Classify added redirects; the probe placeholder keeps rows with no additions alive through mv-apply ---
| extend RedirectProbe = iff(array_length(AddedRedirects) == 0, dynamic([""]), AddedRedirects)
| mv-apply Candidate = RedirectProbe to typeof(string) on (
    summarize UntrustedRedirects = make_set_if(Candidate, isnotempty(Candidate) and not(Candidate has_any (TrustedDomains)) and Candidate !contains "localhost" and Candidate !contains "127.0.0.1"),
              InsecureRedirects = make_set_if(Candidate, isnotempty(Candidate) and Candidate startswith "http://" and Candidate !contains "localhost" and Candidate !contains "127.0.0.1"),
              WildcardRedirects = make_set_if(Candidate, isnotempty(Candidate) and Candidate contains "*")
)
// --- Signal evaluation ---
| extend LogoutRemoved = (isnotempty(OldLogoutUrl) and (isempty(NewLogoutUrl) or NewLogoutUrl =~ "null"))
                      or (array_length(RemovedLogoutUrls) > 0 and array_length(AddedLogoutUrls) == 0),
         AppIdUriChanged = (isnotempty(OldAppIdUri) or isnotempty(NewAppIdUri)) and OldAppIdUri != NewAppIdUri,
         DormantApp = array_length(PriorTouchedApps) > 0 and not(set_has_element(PriorTouchedApps, TargetObjectId)),
         NonAdminActor = IdentityFound == true and isnotempty(InitiatorUPN) and not(tostring(ActorRoles) has_any (AppAdminRoles)),
         AppInitiated = isempty(InitiatorUPN) and isnotempty(InitiatorSPId) and InitiatorSPId !in (TrustedAutomation)
// --- Score the change ---
| extend RiskScore =
      iff(array_length(WildcardRedirects) > 0, W_WildcardRedirect, 0)
    + iff(array_length(UntrustedRedirects) > 0, W_UntrustedRedirect, 0)
    + iff(array_length(InsecureRedirects) > 0, W_InsecureRedirect, 0)
    + iff(LogoutRemoved, W_LogoutRemoved, 0)
    + iff(AppIdUriChanged, W_AppIdUriChanged, 0)
    + iff(DormantApp, W_DormantApp, 0)
    + iff(NonAdminActor, W_NonAdminActor, 0)
    + iff(AppInitiated, W_AppInitiated, 0)
| where RiskScore >= FireThreshold
| extend Risk_1 = iff(array_length(WildcardRedirects) > 0, "Wildcard redirect URI added", ""),
         Risk_2 = iff(array_length(UntrustedRedirects) > 0, "Redirect added outside trusted domains", ""),
         Risk_3 = iff(array_length(InsecureRedirects) > 0, "Plain http redirect added", ""),
         Risk_4 = iff(LogoutRemoved, "Logout URL removed or blanked", ""),
         Risk_5 = iff(AppIdUriChanged, "Application ID URI changed", ""),
         Risk_6 = iff(DormantApp, "App untouched for 14 days before this change", ""),
         Risk_7 = iff(NonAdminActor, "Initiator holds no application admin role", ""),
         Risk_8 = iff(AppInitiated, "Change made by a non-allowlisted service principal", "")
| extend RiskIndicators = trim(@"\s\|\s*$", strcat(
      iff(isnotempty(Risk_1), strcat(Risk_1, " | "), ""),
      iff(isnotempty(Risk_2), strcat(Risk_2, " | "), ""),
      iff(isnotempty(Risk_3), strcat(Risk_3, " | "), ""),
      iff(isnotempty(Risk_4), strcat(Risk_4, " | "), ""),
      iff(isnotempty(Risk_5), strcat(Risk_5, " | "), ""),
      iff(isnotempty(Risk_6), strcat(Risk_6, " | "), ""),
      iff(isnotempty(Risk_7), strcat(Risk_7, " | "), ""),
      iff(isnotempty(Risk_8), strcat(Risk_8, " | "), "")))
| extend UpdatedBy = iff(isnotempty(InitiatorUPN), InitiatorUPN, InitiatorApp),
         InitiatorName = tostring(split(InitiatorUPN, "@", 0)[0]),
         InitiatorUPNSuffix = tostring(split(InitiatorUPN, "@", 1)[0]),
         PrimarySuspectUrl = iff(array_length(WildcardRedirects) > 0, tostring(WildcardRedirects[0]), tostring(UntrustedRedirects[0]))
| project EndTime, TargetAppName, TargetObjectId, OperationName, UpdatedBy, InitiatorUPN, InitiatorName, InitiatorUPNSuffix, InitiatorId, InitiatorIP, InitiatorApp, InitiatorSPId, UserAgent, ActorJobTitle, ActorDepartment, ActorRoles, AddedRedirects, RemovedRedirects, UntrustedRedirects, InsecureRedirects, WildcardRedirects, OldLogoutUrl, NewLogoutUrl, OldAppIdUri, NewAppIdUri, PrimarySuspectUrl, RiskScore, RiskIndicators
| sort by RiskScore desc, EndTime desc

You can also download this as an analytic rule and import it directly to Sentinel.

Follow my repo - GitHub

What You Should Do Next

Populate TrustedDomains before you enable anything, because with the placeholder values every legitimate redirect in your tenant scores as untrusted and the rule is unusable. Run the query in Logs over the full 14 days, review the score distribution, then add your deployment service principals to TrustedAutomation based on what you see rather than what you assume. Once this rule is live, disable the three originals, or you will pay three daily alerts of rent for one detection. If your tenant runs heavy app automation, raise FireThreshold to 9 so only serious signals or three-signal stacks fire.

Class dismissed.

Consent Preferences