Skip to content

Identity and Workload Access

Run these hunts in Microsoft Sentinel > Logs. They use Entra SigninLogs, AuditLogs, and, where noted, AADServicePrincipalSignInLogs; verify that each table is ingesting current data before relying on a quiet result. The sequences below prioritize investigation, but no single match proves compromise.

Password or MFA pressure ending in success

Find accounts with repeated password failures or MFA declines/non-response followed by a successful sign-in in the same short window.

Run this first

Pressure followed by success
let Lookback = 1d;
let Window = 10m;
SigninLogs
| where TimeGenerated >= ago(Lookback)
| mv-expand AuthStep = todynamic(AuthenticationDetails)
| extend StepResult = tostring(AuthStep.authenticationStepResultDetail)
| extend IsPressure = tostring(ResultType) in ("50055", "50126", "50057")
    or StepResult in~ (
        "MFA denied; user declined the authentication",
        "MFA denied; user did not respond to mobile app notification"
    )
| extend IsSuccess = tostring(ResultType) == "0" or StepResult =~ "MFA successfully completed"
| where IsPressure or IsSuccess
| summarize
    PressureCount = countif(IsPressure),
    LastPressure = maxif(TimeGenerated, IsPressure),
    SuccessTime = minif(TimeGenerated, IsSuccess),
    PressureIPs = make_set_if(IPAddress, IsPressure, 10),
    SuccessIPs = make_set_if(IPAddress, IsSuccess, 10),
    Countries = make_set(tostring(LocationDetails.countryOrRegion), 10),
    Apps = make_set(AppDisplayName, 10)
  by UserPrincipalName, bin(TimeGenerated, Window)
| where PressureCount >= 5 and isnotnull(SuccessTime) and LastPressure <= SuccessTime
| project WindowStart = TimeGenerated, SuccessTime, UserPrincipalName, PressureCount,
          PressureIPs, SuccessIPs, Countries, Apps
| sort by SuccessTime desc

Pay attention to

  • 50055, 50126, 50057, explicit MFA denial/non-response, and a different success IP or country.
  • A new device, application, autonomous system, or success immediately after the last denial.

Then pivot

Post-success identity activity
let Lookback = 1d;
let Window = 10m;
let Candidates = materialize(
    SigninLogs
    | where TimeGenerated >= ago(Lookback)
    | mv-expand AuthStep = todynamic(AuthenticationDetails)
    | extend StepResult = tostring(AuthStep.authenticationStepResultDetail)
    | extend IsPressure = tostring(ResultType) in ("50055", "50126", "50057")
        or StepResult in~ (
            "MFA denied; user declined the authentication",
            "MFA denied; user did not respond to mobile app notification"
        )
    | extend IsSuccess = tostring(ResultType) == "0" or StepResult =~ "MFA successfully completed"
    | where IsPressure or IsSuccess
    | summarize PressureCount=countif(IsPressure), LastPressure=maxif(TimeGenerated, IsPressure),
                SuccessTime=minif(TimeGenerated, IsSuccess)
      by UserPrincipalName, bin(TimeGenerated, Window)
    | where PressureCount >= 5 and isnotnull(SuccessTime) and LastPressure <= SuccessTime
    | project UserPrincipalName=tolower(UserPrincipalName), SuccessTime
);
union isfuzzy=true
(
    SigninLogs
    | where TimeGenerated >= ago(Lookback)
    | project EventTime=TimeGenerated, UserPrincipalName=tolower(UserPrincipalName),
              EventType="Sign-in", Activity=AppDisplayName, IPAddress, Result=tostring(ResultType),
              Detail=strcat(tostring(DeviceDetail), " ", tostring(ConditionalAccessStatus)), CorrelationId
),
(
    AuditLogs
    | where TimeGenerated >= ago(Lookback)
    | extend UserPrincipalName=tolower(coalesce(
        tostring(InitiatedBy.user.userPrincipalName),
        tostring(TargetResources[0].userPrincipalName)))
    | project EventTime=TimeGenerated, UserPrincipalName, EventType="Audit", Activity=OperationName,
              IPAddress=tostring(InitiatedBy.user.ipAddress), Result, Detail=tostring(TargetResources), CorrelationId
)
| join kind=inner Candidates on UserPrincipalName
| where EventTime between (SuccessTime .. SuccessTime + 2h)
| project EventTime, SuccessTime, UserPrincipalName, EventType, Activity, IPAddress, Result, Detail, CorrelationId
| sort by UserPrincipalName asc, EventTime asc

Needs: Sentinel Log Analytics; SigninLogs and AuditLogs from the Microsoft Entra ID connector.

Expected noise: Password expiry, mistyped passwords, delayed push responses, travel, VPN changes, and legitimate help-desk recovery.

Source YAML: MFASpammingfollowedbySuccessfullogin.yaml — Solutions/Microsoft Entra ID/Analytic Rules/MFASpammingfollowedbySuccessfullogin.yaml

Suspicious sign-in followed by authentication takeover

Correlate a medium/high-risk successful sign-in with a nearby authentication-method, Temporary Access Pass, or device-registration change for the same identity.

Run this first

Risky sign-in near authentication takeover
let Lookback = 2d;
let CorrelationWindow = 30m;
let RiskySignIns = materialize(
    SigninLogs
    | where TimeGenerated >= ago(Lookback)
    | where tostring(ResultType) == "0"
    | where RiskLevelDuringSignIn in~ ("medium", "high")
        or RiskState in~ ("atRisk", "confirmedCompromised")
    | project RiskyTime=TimeGenerated, UserId=tostring(UserId), UserPrincipalName,
              RiskyIP=IPAddress, RiskyLocation=tostring(Location), RiskLevelDuringSignIn,
              RiskState, RiskEvents=tostring(RiskEventTypes_V2), AppDisplayName
);
AuditLogs
| where TimeGenerated >= ago(Lookback)
| where Result =~ "success"
| where OperationName in~ (
    "User registered security info",
    "Admin registered security info",
    "User changed default security info",
    "Admin updated security info",
    "Add authentication method",
    "Create Temporary Access Pass",
    "Register device",
    "Add registered owner to device"
)
| extend TargetUserId=tostring(TargetResources[0].id),
         InitiatingUserId=tostring(InitiatedBy.user.id)
| extend UserId=iff(OperationName has_any ("device", "Device"), InitiatingUserId, TargetUserId)
| project ChangeTime=TimeGenerated, UserId, OperationName,
          ChangeActor=tostring(InitiatedBy.user.userPrincipalName),
          ChangeIP=tostring(InitiatedBy.user.ipAddress), TargetResources, CorrelationId
| join kind=inner RiskySignIns on UserId
| where ChangeTime between (RiskyTime - CorrelationWindow .. RiskyTime + CorrelationWindow)
| project RiskyTime, ChangeTime, UserPrincipalName, RiskLevelDuringSignIn, RiskState,
          RiskEvents, RiskyIP, RiskyLocation, AppDisplayName, OperationName,
          ChangeActor, ChangeIP, TargetResources, CorrelationId, UserId
| sort by RiskyTime desc

Pay attention to

  • High risk, unfamiliar properties, anonymous IP, impossible travel, or a change IP unlike the sign-in IP.
  • Admin registered security info, default-method changes, Temporary Access Pass creation, or a newly owned/registered device.

Then pivot

Activity after a risky takeover sequence
let Lookback = 2d;
let CorrelationWindow = 30m;
let TakeoverUsers = materialize(
    let Risky = SigninLogs
        | where TimeGenerated >= ago(Lookback) and tostring(ResultType) == "0"
        | where RiskLevelDuringSignIn in~ ("medium", "high") or RiskState in~ ("atRisk", "confirmedCompromised")
        | project RiskyTime=TimeGenerated, UserId=tostring(UserId), UserPrincipalName;
    AuditLogs
    | where TimeGenerated >= ago(Lookback) and Result =~ "success"
    | where OperationName in~ (
        "User registered security info", "Admin registered security info",
        "User changed default security info", "Admin updated security info",
        "Add authentication method", "Create Temporary Access Pass",
        "Register device", "Add registered owner to device"
    )
    | extend TargetUserId=tostring(TargetResources[0].id), InitiatingUserId=tostring(InitiatedBy.user.id)
    | extend UserId=iff(OperationName has_any ("device", "Device"), InitiatingUserId, TargetUserId)
    | project ChangeTime=TimeGenerated, UserId
    | join kind=inner Risky on UserId
    | where ChangeTime between (RiskyTime - CorrelationWindow .. RiskyTime + CorrelationWindow)
    | summarize SequenceTime=max(ChangeTime) by UserId, UserPrincipalName=tolower(UserPrincipalName)
);
SigninLogs
| where TimeGenerated >= ago(Lookback)
| extend UserPrincipalName=tolower(UserPrincipalName)
| join kind=inner TakeoverUsers on UserPrincipalName
| where TimeGenerated between (SequenceTime .. SequenceTime + 6h)
| project TimeGenerated, SequenceTime, UserPrincipalName, AppDisplayName, IPAddress,
          Location, DeviceDetail, AuthenticationRequirement, ConditionalAccessStatus,
          ResultType, RiskLevelDuringSignIn, RiskState, CorrelationId
| sort by UserPrincipalName asc, TimeGenerated asc

Needs: Sentinel Log Analytics; SigninLogs and AuditLogs from Microsoft Entra ID; risk fields require Entra ID Protection data/licensing.

Expected noise: Legitimate registration after a risk challenge, device enrollment, account recovery, and help-desk-administered MFA changes.

Source YAML: HighRiskSignInAroundAuthMethodOrDeviceRegistration.yaml — Hunting Queries/MultipleDataSources/HighRiskSignInAroundAuthMethodOrDeviceRegistration.yaml

OAuth/workload-identity persistence

Surface high-impact consent and app/service-principal lifecycle changes, then test whether the changed workload identity was used.

Run this first

OAuth and workload-identity persistence changes
let Lookback = 7d;
let HighImpact = dynamic([
    "Mail.ReadWrite", "Mail.Send", "Files.ReadWrite.All",
    "Directory.ReadWrite.All", "RoleManagement.ReadWrite.Directory",
    "AppRoleAssignment.ReadWrite.All", "full_access_as_app", "AllPrincipals"
]);
AuditLogs
| where TimeGenerated >= ago(Lookback) and Result =~ "success"
| where OperationName in~ (
    "Consent to application",
    "Add app role assignment to service principal",
    "Add service principal credentials",
    "Update application - Certificates and secrets management",
    "Add owner to service principal",
    "Add owner to application",
    "Update service principal",
    "Update application"
)
| mv-expand Target = TargetResources
| mv-expand ModProp = Target.modifiedProperties
| extend Property=tostring(ModProp.displayName), OldValue=tostring(ModProp.oldValue),
         NewValue=tostring(ModProp.newValue), TargetId=tostring(Target.id),
         TargetName=tostring(Target.displayName), TargetType=tostring(Target.type)
| where OperationName !in~ ("Update service principal", "Update application")
    or Property in~ ("FederatedIdentityCredentials", "ReplyUrls", "PasswordCredentials", "KeyCredentials")
    or NewValue has_any (HighImpact)
| extend Actor=coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
         ActorIP=coalesce(tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))
| extend HighImpactMatch=NewValue has_any (HighImpact)
| project TimeGenerated, OperationName, TargetName, TargetId, TargetType, Property,
          OldValue, NewValue, HighImpactMatch, Actor, ActorIP, CorrelationId
| sort by TimeGenerated desc

Pay attention to

  • AllPrincipals, high-impact scopes, new secrets/certificates, FederatedIdentityCredentials, owners, or ReplyUrls.
  • First-seen immutable app/service-principal IDs, automation-origin actors, and changes outside an approved deployment.

Then pivot

Use after workload-identity change
let Lookback = 7d;
let UseWindow = 24h;
let ChangedServicePrincipals = materialize(
    AuditLogs
    | where TimeGenerated >= ago(Lookback) and Result =~ "success"
    | where OperationName in~ (
        "Add app role assignment to service principal", "Add service principal credentials",
        "Add owner to service principal", "Update service principal"
    )
    | mv-expand Target = TargetResources
    | where tostring(Target.type) =~ "ServicePrincipal"
    | project ChangeTime=TimeGenerated, ServicePrincipalId=tostring(Target.id),
              ServicePrincipalName=tostring(Target.displayName), ChangeOperation=OperationName,
              ChangeActor=coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
              ChangeCorrelationId=CorrelationId
);
AADServicePrincipalSignInLogs
| where TimeGenerated >= ago(Lookback) and tostring(ResultType) == "0"
| join kind=inner ChangedServicePrincipals on ServicePrincipalId
| where TimeGenerated between (ChangeTime .. ChangeTime + UseWindow)
| project TimeGenerated, ChangeTime, ServicePrincipalName, ServicePrincipalId, AppId,
          ChangeOperation, ChangeActor, IPAddress, Location, ResourceDisplayName,
          SignInCorrelationId=CorrelationId, ChangeCorrelationId
| sort by ServicePrincipalId asc, TimeGenerated asc

Needs: Sentinel Log Analytics; AuditLogs plus AADServicePrincipalSignInLogs from Microsoft Entra ID workload-identity sign-in ingestion.

Expected noise: Approved app onboarding, CI/CD federation, certificate rotation, consent renewals, and identity-governance automation.

Source YAML: OAuthConsentToHighRiskPermissionScope.yaml — Hunting Queries/MultipleDataSources/OAuthConsentToHighRiskPermissionScope.yaml; ServicePrincipalCredentialAddedThenSignedIn.yaml — Hunting Queries/MultipleDataSources/ServicePrincipalCredentialAddedThenSignedIn.yaml; ServicePrincipalFederatedIdentityCredentialAdded.yaml — Hunting Queries/AuditLogs/ServicePrincipalFederatedIdentityCredentialAdded.yaml

Fresh privilege used immediately

Find a newly granted privileged role that the recipient uses within 24 hours for a high-impact identity or policy change.

Run this first

Fresh privilege followed by high-impact change
let Lookback = 7d;
let UseWindow = 24h;
let HighImpactRoles = dynamic([
    "Global Administrator", "Privileged Role Administrator",
    "Application Administrator", "Cloud Application Administrator",
    "Authentication Administrator", "Privileged Authentication Administrator",
    "Conditional Access Administrator", "Security Administrator"
]);
let Grants = materialize(
    AuditLogs
    | where TimeGenerated >= ago(Lookback) and Result =~ "success"
    | where OperationName in~ ("Add member to role", "Add member to role.")
    | extend RecipientUPN=tolower(tostring(TargetResources[0].userPrincipalName)),
             RecipientId=tostring(TargetResources[0].id),
             RoleName=tostring(TargetResources[1].displayName)
    | where RoleName in~ (HighImpactRoles) and isnotempty(RecipientUPN)
    | project GrantTime=TimeGenerated, RecipientUPN, RecipientId, RoleName,
              GrantActor=coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
              GrantActorIP=coalesce(tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress)),
              GrantCorrelationId=CorrelationId
);
AuditLogs
| where TimeGenerated >= ago(Lookback) and Result =~ "success"
| extend RecipientUPN=tolower(tostring(InitiatedBy.user.userPrincipalName))
| where OperationName in~ (
    "Add service principal credentials", "Update application - Certificates and secrets management",
    "Add owner to service principal", "Add owner to application",
    "Add member to role", "Add member to role.",
    "Update conditional access policy", "Delete conditional access policy",
    "Set domain authentication", "Admin registered security info", "Admin updated security info"
)
| project UseTime=TimeGenerated, RecipientUPN, UseOperation=OperationName,
          UseIP=tostring(InitiatedBy.user.ipAddress), UseTarget=tostring(TargetResources),
          UseCorrelationId=CorrelationId
| join kind=inner Grants on RecipientUPN
| where UseTime between (GrantTime .. GrantTime + UseWindow)
| project GrantTime, UseTime, RecipientUPN, RecipientId, RoleName, GrantActor,
          GrantActorIP, UseOperation, UseIP, UseTarget, GrantCorrelationId, UseCorrelationId
| sort by UseTime desc

Pay attention to

  • Global, role, application, authentication, Conditional Access, or security administration followed by an immediate sensitive action.
  • A newly created recipient, grant/use IP mismatch, unusual grantor, or unrelated grant and use correlation IDs.

Then pivot

Sign-ins around newly exercised privilege
let Lookback = 7d;
let HighImpactRoles = dynamic([
    "Global Administrator", "Privileged Role Administrator",
    "Application Administrator", "Cloud Application Administrator",
    "Authentication Administrator", "Privileged Authentication Administrator",
    "Conditional Access Administrator", "Security Administrator"
]);
let FreshRecipients = materialize(
    AuditLogs
    | where TimeGenerated >= ago(Lookback) and Result =~ "success"
    | where OperationName in~ ("Add member to role", "Add member to role.")
    | extend UserPrincipalName=tolower(tostring(TargetResources[0].userPrincipalName)),
             RoleName=tostring(TargetResources[1].displayName)
    | where RoleName in~ (HighImpactRoles) and isnotempty(UserPrincipalName)
    | summarize GrantTime=min(TimeGenerated), Roles=make_set(RoleName, 10) by UserPrincipalName
);
SigninLogs
| where TimeGenerated >= ago(Lookback)
| extend UserPrincipalName=tolower(UserPrincipalName)
| join kind=inner FreshRecipients on UserPrincipalName
| where TimeGenerated between (GrantTime - 2h .. GrantTime + 26h)
| project TimeGenerated, GrantTime, UserPrincipalName, Roles, AppDisplayName, IPAddress,
          Location, DeviceDetail, AuthenticationRequirement, ConditionalAccessStatus,
          ResultType, RiskLevelDuringSignIn, RiskState, CorrelationId
| sort by UserPrincipalName asc, TimeGenerated asc

Needs: Sentinel Log Analytics; AuditLogs and SigninLogs from Microsoft Entra ID; role names must match the tenant's emitted values.

Expected noise: PIM activation, planned emergency access, app maintenance, administrator onboarding, and documented break-glass testing.

Source YAML: FreshRoleGrantedActorSpCredentialAdded.yaml — Hunting Queries/AuditLogs/FreshRoleGrantedActorSpCredentialAdded.yaml; PrivilegedRoleAssignedToNewAccount.yaml — Hunting Queries/AuditLogs/PrivilegedRoleAssignedToNewAccount.yaml

Security/trust boundary weakened

Surface successful Conditional Access, federation, guest, or external-trust changes with the exact old and new values for review.

Run this first

Security or trust boundary weakened
let Lookback = 14d;
let Changed = AuditLogs
    | where TimeGenerated >= ago(Lookback) and Result =~ "success"
    | where OperationName in~ (
        "Update conditional access policy", "Set domain authentication",
        "Update crossTenantAccessPolicy", "Update partner cross-tenant access setting",
        "Update user", "Update application", "Update service principal"
    )
    | mv-expand Target = TargetResources
    | mv-expand ModProp = Target.modifiedProperties
    | extend Property=tostring(ModProp.displayName), OldValue=tostring(ModProp.oldValue),
             NewValue=tostring(ModProp.newValue), TargetName=tostring(Target.displayName),
             TargetId=tostring(Target.id)
    | where (OperationName =~ "Update conditional access policy"
             and (Property =~ "State" or Property has "Condition" or NewValue has "exclude" or OldValue has "exclude"))
        or (OperationName =~ "Set domain authentication" and NewValue has "Federated")
        or OperationName has_any ("crossTenant", "cross-tenant")
        or (OperationName =~ "Update user" and Property =~ "UserType" and OldValue has "Guest" and NewValue has "Member")
        or (OperationName in~ ("Update application", "Update service principal")
            and Property in~ ("ReplyUrls", "FederatedIdentityCredentials"));
let Deleted = AuditLogs
    | where TimeGenerated >= ago(Lookback) and Result =~ "success"
    | where OperationName =~ "Delete conditional access policy"
    | extend TargetName=tostring(TargetResources[0].displayName), TargetId=tostring(TargetResources[0].id),
             Property="State", OldValue="enabled", NewValue="deleted";
union Changed, Deleted
| extend Actor=coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
         ActorIP=coalesce(tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))
| project TimeGenerated, OperationName, TargetName, TargetId, Property, OldValue, NewValue,
          Actor, ActorIP, CorrelationId
| sort by TimeGenerated desc

Pay attention to

  • Enabled-to-disabled/deleted policy state, new exclusions, guest-to-member conversion, new partner trust, federation, external redirects, or federated credentials.
  • Old/new values that add privileged or newly created identities; object IDs may need resolution before judging scope.

Then pivot

Sign-ins by newly exempted identities
let Lookback = 14d;
let PostChangeWindow = 24h;
let NewlyExempted = materialize(
    AuditLogs
    | where TimeGenerated >= ago(Lookback) and Result =~ "success"
    | where OperationName =~ "Update conditional access policy"
    | mv-expand ModProp = TargetResources[0].modifiedProperties
    | extend Property=tostring(ModProp.displayName), NewValue=tostring(ModProp.newValue)
    | where Property has "Condition" or NewValue has "exclude"
    | extend UserPrincipalNames=extract_all(@"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", NewValue)
    | mv-expand UserPrincipalName=UserPrincipalNames
    | project ChangeTime=TimeGenerated, UserPrincipalName=tolower(tostring(UserPrincipalName)),
              PolicyName=tostring(TargetResources[0].displayName),
              ChangeActor=coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
              ChangeCorrelationId=CorrelationId
);
SigninLogs
| where TimeGenerated >= ago(Lookback)
| extend UserPrincipalName=tolower(UserPrincipalName)
| join kind=inner NewlyExempted on UserPrincipalName
| where TimeGenerated between (ChangeTime .. ChangeTime + PostChangeWindow)
| project TimeGenerated, ChangeTime, UserPrincipalName, PolicyName, ChangeActor,
          AppDisplayName, IPAddress, Location, DeviceDetail, ConditionalAccessStatus,
          ResultType, RiskLevelDuringSignIn, RiskState, SignInCorrelationId=CorrelationId,
          ChangeCorrelationId
| sort by UserPrincipalName asc, TimeGenerated asc

Needs: Sentinel Log Analytics; AuditLogs and SigninLogs from Microsoft Entra ID; resolve GUID-only exclusions separately because the pivot extracts UPNs only.

Expected noise: Approved policy tuning, break-glass exclusions, federation migrations, partner onboarding, guest lifecycle changes, and application redirect maintenance.

Source YAML: ConditionalAccessPolicyExclusionAdded.yaml — Hunting Queries/AuditLogs/ConditionalAccessPolicyExclusionAdded.yaml; ConditionalAccessPolicyDisabledOrDeleted.yaml — Hunting Queries/AuditLogs/ConditionalAccessPolicyDisabledOrDeleted.yaml; FederatedDomainAddedToTenant.yaml — Hunting Queries/AuditLogs/FederatedDomainAddedToTenant.yaml; GuestUserTypeChangedToMember.yaml — Hunting Queries/AuditLogs/GuestUserTypeChangedToMember.yaml