Behavior-based detection engineering
SSPR Reset Followed by MFA Method Replacement and New Sign-In
Detects successful self-service password reset followed by authentication-method changes and a first-seen successful sign-in, then exposes Graph discovery and application-persistence pivots.
Behavior
What it detects
A successful self-service password reset is followed by authentication-method changes and a successful sign-in from a source not seen for the user in the recent baseline.
Engineering decision
Why this detection
SSPR by itself is expected. Registering a new authentication method can also be expected. A new source IP can be expected.
The useful detection surface is the sequence.
The primary analytic therefore requires a successful self-service reset, authentication-method change shortly afterward, and a successful sign-in from a source absent from the user's recent successful-sign-in baseline.
The drilldowns then ask whether the account behaved like Microsoft's Storm-2949 intrusion: broad Graph discovery or an attempt to create application/service-principal persistence.
Signal chain
Detection logic
- Collect successful self-service password-reset events.
- Collect successful authentication-method deletion, registration, update, and default-method changes.
- Correlate method changes to the same user within thirty minutes after the reset.
- Build a thirty-day per-user baseline of successful sign-in source IPs.
- Require a successful sign-in from a source absent from that baseline within two hours after the reset.
- Preserve risk, app/resource, device, user agent, session, and correlation context for triage.
- Use Graph and application-identity drilldowns only after the recovery sequence produces a candidate.
Primary analytic
Query
Q-01Detection logicSSPR reset followed by authentication-method change and new sign-in
What this checks
Generate a candidate when a successful self-service reset is followed by security-information changes and a successful sign-in from a source not seen for the user in the recent baseline.
KQL
let current_window = 1d;
let baseline_window = 30d;
let resets =
AuditLogs
| where TimeGenerated >= ago(current_window)
| where LoggedByService =~ "Self-service Password Management"
| where OperationName =~ "Reset password (self-service)"
| where Result =~ "success"
| extend
TargetUser = tolower(tostring(TargetResources[0].userPrincipalName)),
TargetUserId = tostring(TargetResources[0].id),
ResetCorrelationId = CorrelationId
| where isnotempty(TargetUser)
| project ResetTime=TimeGenerated, TargetUser, TargetUserId, ResetCorrelationId;
let auth_changes =
AuditLogs
| where TimeGenerated >= ago(current_window)
| where Result =~ "success"
| where OperationName in~ (
"User deleted security information",
"User registered security info",
"Delete user authentication method",
"Add user authentication method",
"Update user authentication methods",
"User changed default security information"
)
| extend
TargetUser = tolower(tostring(TargetResources[0].userPrincipalName)),
InitiatingIP = tostring(InitiatedBy.user.ipAddress)
| project
AuthChangeTime=TimeGenerated,
TargetUser,
AuthChangeOperation=OperationName,
InitiatingIP,
AuthChangeCorrelationId=CorrelationId;
let historical_sources =
SigninLogs
| where TimeGenerated between (ago(baseline_window) .. ago(current_window))
| where tostring(ResultType) == "0"
| extend TargetUser=tolower(UserPrincipalName)
| summarize by TargetUser, IPAddress;
let post_reset_signins =
SigninLogs
| where TimeGenerated >= ago(current_window)
| where tostring(ResultType) == "0"
| extend TargetUser=tolower(UserPrincipalName)
| join kind=leftanti historical_sources on TargetUser, IPAddress
| project
SigninTime=TimeGenerated,
TargetUser,
IPAddress,
AppDisplayName,
AppId,
ResourceDisplayName,
AuthenticationRequirement,
RiskLevelDuringSignIn,
RiskLevelAggregated,
RiskState,
ConditionalAccessStatus,
DeviceDetail,
UserAgent,
SigninCorrelationId=CorrelationId,
SessionId;
resets
| join kind=inner auth_changes on TargetUser
| where AuthChangeTime between (ResetTime .. ResetTime + 30m)
| summarize
AuthChangeOperations=make_set(AuthChangeOperation, 20),
AuthChangeCount=count(),
FirstAuthChange=min(AuthChangeTime),
LastAuthChange=max(AuthChangeTime),
AuthChangeIPs=make_set(InitiatingIP, 20),
AuthCorrelations=make_set(AuthChangeCorrelationId, 20)
by ResetTime, TargetUser, TargetUserId, ResetCorrelationId
| where AuthChangeCount >= 1
| join kind=inner post_reset_signins on TargetUser
| where SigninTime between (ResetTime .. ResetTime + 2h)
| project
ResetTime,
FirstAuthChange,
LastAuthChange,
SigninTime,
TargetUser,
TargetUserId,
AuthChangeCount,
AuthChangeOperations,
AuthChangeIPs,
IPAddress,
AppDisplayName,
ResourceDisplayName,
RiskLevelDuringSignIn,
RiskLevelAggregated,
RiskState,
ConditionalAccessStatus,
DeviceDetail,
UserAgent,
SessionId,
ResetCorrelationId,
SigninCorrelationId
| order by SigninTime descSPL
(
index=<entra_audit_index> sourcetype=<entra_audit_sourcetype> earliest=-1d
)
OR
(
index=<entra_signin_index> sourcetype=<entra_signin_sourcetype> earliest=-30d
)
| eval
user=lower(coalesce(user, UserPrincipalName, TargetUserPrincipalName, user_principal_name)),
operation=coalesce(operation, OperationName, ActivityDisplayName),
src=coalesce(src, IPAddress, InitiatingIpAddress, ipAddress),
result=lower(coalesce(result, Result, ResultType)),
correlation_id=coalesce(correlation_id, CorrelationId),
event_type=case(
operation="Reset password (self-service)" AND result="success","reset",
operation IN (
"User deleted security information",
"User registered security info",
"Delete user authentication method",
"Add user authentication method",
"Update user authentication methods",
"User changed default security information"
) AND result="success","auth_change",
tostring(result)="0","signin",
true(),"other"
),
is_current=if(_time>=relative_time(now(),"-1d"),1,0)
| eventstats
values(eval(if(event_type="signin" AND is_current=0,src,null()))) as historical_signin_ips
by user
| sort 0 user _time
| streamstats current=f
last(eval(if(event_type="reset" AND is_current=1,_time,null()))) as reset_time
last(eval(if(event_type="auth_change" AND is_current=1,_time,null()))) as auth_change_time
last(eval(if(event_type="auth_change" AND is_current=1,operation,null()))) as auth_change_operation
by user
| where
event_type="signin"
AND is_current=1
AND isnotnull(reset_time)
AND isnotnull(auth_change_time)
AND auth_change_time>=reset_time
AND auth_change_time<=reset_time+1800
AND _time>=reset_time
AND _time<=reset_time+7200
AND mvfind(historical_signin_ips,src)<0
| table
_time reset_time auth_change_time user src auth_change_operation
AppDisplayName ResourceDisplayName RiskLevelDuringSignIn RiskState
DeviceDetail UserAgent SessionId correlation_id
| sort - _timeWhat to look for
A compact sequence that is unusual for normal end-user recovery and closely resembles the source-reported SSPR takeover path.
Technical details
Tested signal
SSPR success + authentication-method churn within thirty minutes + first-seen successful sign-in within two hours.
Assumptions
- AuditLogs and SigninLogs can be joined by normalized user principal name.
- Thirty days is a useful starting baseline for successful sign-in source IPs.
- Authentication-method audit event names are normalized to the documented Entra activity names.
Data requirements and relevant fields
- identity
Microsoft Entra audit events for self-service password reset and authentication-method changes.
TimeGeneratedLoggedByServiceCategoryOperationNameResultResultReasonInitiatedByTargetResourcesAdditionalDetailsCorrelationId
- authentication
Microsoft Entra sign-in events with source, app/resource, device, risk, and session/correlation context.
TimeGeneratedUserPrincipalNameUserIdIPAddressAppDisplayNameAppIdResourceDisplayNameResultTypeResultDescriptionConditionalAccessStatusAuthenticationRequirementRiskLevelDuringSignInRiskLevelAggregatedRiskStateDeviceDetailUserAgentAutonomousSystemNumberCorrelationIdSessionId
KQL schema
Validate connector availability, nested-field shape, retention, and licensing before operational use.
SPL schema
Replace index/sourcetype placeholders and map the documented identity, Graph, and Azure control-plane concepts to local fields.
Limitations
- Legitimate lost-device recovery while traveling can create the same three-stage sequence.
- Source-IP novelty should be tuned around VPN, SWG, mobile carrier, VDI, and known recovery locations.
- The rule does not assume that every SSPR reset or MFA registration is malicious.
The analytic correlates Entra audit and sign-in data. Keep the three signals separate during local mapping so normal SSPR volume, authentication-method registration, and network baselines can be tuned independently.
Analyst workflow
What the analyst should look for
- Did the user intentionally initiate SSPR?
- Was the reset performed under an approved helpdesk or identity-recovery workflow?
- Which authentication methods were deleted, added, or made default after the reset?
- Does the new sign-in source belong to an expected VPN, SWG, VDI, mobile, or travel scenario?
- Is the new device or user agent known to the user?
- Did the candidate identity enumerate users, applications, service principals, or roles through Microsoft Graph?
- Did the user attempt to add credentials to an application or service principal?
- Do Azure control-plane actions show expansion into high-value resources?
Expected result
A compact sequence that is unusual for normal end-user recovery and closely resembles the source-reported SSPR takeover path.
Investigation pivots
Drilldowns
View query — Reconstruct the password-recovery flow
Q-02DrilldownReconstruct the password-recovery flow
What this checks
Review the complete SSPR flow for the candidate user rather than only the final successful reset.
KQL
let target_user = "<candidate_user>";
AuditLogs
| where TimeGenerated >= ago(7d)
| where LoggedByService =~ "Self-service Password Management"
| where OperationName in~ (
"Reset password (self-service)",
"Self-service password reset flow activity progress",
"Blocked from self-service password reset"
)
| extend
InitiatingUser = tolower(tostring(InitiatedBy.user.userPrincipalName)),
TargetUser = tolower(tostring(TargetResources[0].userPrincipalName)),
TargetId = tostring(TargetResources[0].id)
| where InitiatingUser == tolower(target_user) or TargetUser == tolower(target_user)
| project
TimeGenerated,
OperationName,
Result,
ResultReason,
InitiatingUser,
TargetUser,
TargetId,
AdditionalDetails,
CorrelationId
| order by TimeGenerated ascSPL
index=<entra_audit_index> sourcetype=<entra_audit_sourcetype>
earliest=-7d
(
OperationName="Reset password (self-service)"
OR OperationName="Self-service password reset flow activity progress"
OR OperationName="Blocked from self-service password reset"
)
| eval
user=lower(coalesce(user, userPrincipalName, InitiatingUserPrincipalName, TargetUserPrincipalName)),
operation=coalesce(operation, OperationName, ActivityDisplayName),
result=lower(coalesce(result, Result)),
correlation_id=coalesce(correlation_id, CorrelationId)
| where user="<candidate_user>"
| fields _time user operation result ResultReason correlation_id AdditionalDetails
| sort 0 _timeWhat to look for
The sequence of reset-flow events and the final successful reset for the same user.
Technical details
Tested signal
Reset flow progress, blocks, and successful self-service reset for the candidate identity.
Assumptions
- Replace the candidate user from Q-01.
Data requirements and relevant fields
- identity
Microsoft Entra audit events for self-service password reset and authentication-method changes.
TimeGeneratedLoggedByServiceCategoryOperationNameResultResultReasonInitiatedByTargetResourcesAdditionalDetailsCorrelationId
KQL schema
Validate connector availability, nested-field shape, retention, and licensing before operational use.
SPL schema
Replace index/sourcetype placeholders and map the documented identity, Graph, and Azure control-plane concepts to local fields.
Limitations
- SSPR flow details can vary with tenant policy and available recovery methods.
KQL uses Microsoft Entra / Azure Monitor telemetry. SPL is a normalized raw-event scaffold and requires local field mapping.
View query — Review authentication-method replacement
Q-03DrilldownReview authentication-method replacement
What this checks
List security-information deletion, registration, and default-method changes around the candidate reset.
KQL
let target_user = "<candidate_user>";
AuditLogs
| where TimeGenerated >= ago(7d)
| where OperationName in~ (
"User deleted security information",
"User registered security info",
"Delete user authentication method",
"Add user authentication method",
"Update user authentication methods",
"User changed default security information"
)
| extend
InitiatingUser = tolower(tostring(InitiatedBy.user.userPrincipalName)),
TargetUser = tolower(tostring(TargetResources[0].userPrincipalName)),
TargetId = tostring(TargetResources[0].id),
InitiatingIP = tostring(InitiatedBy.user.ipAddress)
| where InitiatingUser == tolower(target_user) or TargetUser == tolower(target_user)
| project
TimeGenerated,
OperationName,
Result,
ResultReason,
InitiatingUser,
TargetUser,
TargetId,
InitiatingIP,
TargetResources,
AdditionalDetails,
CorrelationId
| order by TimeGenerated ascSPL
index=<entra_audit_index> sourcetype=<entra_audit_sourcetype>
earliest=-7d
(
OperationName="User deleted security information"
OR OperationName="User registered security info"
OR OperationName="Delete user authentication method"
OR OperationName="Add user authentication method"
OR OperationName="Update user authentication methods"
OR OperationName="User changed default security information"
)
| eval
user=lower(coalesce(user, userPrincipalName, InitiatingUserPrincipalName, TargetUserPrincipalName)),
operation=coalesce(operation, OperationName, ActivityDisplayName),
src=coalesce(src, InitiatingIpAddress, ipAddress),
result=lower(coalesce(result, Result)),
correlation_id=coalesce(correlation_id, CorrelationId)
| where user="<candidate_user>"
| fields _time user src operation result ResultReason correlation_id TargetResources AdditionalDetails
| sort 0 _timeWhat to look for
Security methods deleted or added in the reset window, with the initiating IP and correlation context preserved.
Technical details
Tested signal
Authentication-method churn for the candidate identity.
Assumptions
- Replace the candidate user from Q-01.
Data requirements and relevant fields
- identity
Microsoft Entra audit events for self-service password reset and authentication-method changes.
TimeGeneratedLoggedByServiceCategoryOperationNameResultResultReasonInitiatedByTargetResourcesAdditionalDetailsCorrelationId
KQL schema
Validate connector availability, nested-field shape, retention, and licensing before operational use.
SPL schema
Replace index/sourcetype placeholders and map the documented identity, Graph, and Azure control-plane concepts to local fields.
Limitations
- A legitimate lost-device or authenticator migration can create similar events.
KQL uses Microsoft Entra / Azure Monitor telemetry. SPL is a normalized raw-event scaffold and requires local field mapping.
View query — Measure Graph enumeration after takeover
Q-04DrilldownMeasure Graph enumeration after takeover
What this checks
Check whether the candidate identity rapidly enumerated directory and application objects after the suspicious recovery sequence.
KQL
let target_user_id = "<candidate_user_object_id>";
MicrosoftGraphActivityLogs
| where TimeGenerated >= ago(7d)
| where UserId == target_user_id
| where RequestMethod =~ "GET"
| where ResponseStatusCode between (200 .. 299)
| where RequestUri has_any (
"/users",
"/applications",
"/servicePrincipals",
"/directoryRoles",
"/roleManagement/directory"
)
| extend EndpointFamily = case(
RequestUri has "/servicePrincipals", "servicePrincipals",
RequestUri has "/applications", "applications",
RequestUri has "/roleManagement/directory", "roleManagement",
RequestUri has "/directoryRoles", "directoryRoles",
RequestUri has "/users", "users",
"other"
)
| summarize
FirstSeen=min(TimeGenerated),
LastSeen=max(TimeGenerated),
Requests=count(),
EndpointFamilies=dcount(EndpointFamily),
Families=make_set(EndpointFamily, 10),
SampleUris=make_set(RequestUri, 40),
StatusCodes=make_set(ResponseStatusCode, 10)
by UserId, IPAddress, AppId, SessionId, UserAgent
| where Requests >= 20 and EndpointFamilies >= 3
| order by Requests descSPL
index=<graph_activity_index> sourcetype=<microsoft_graph_activity_sourcetype>
earliest=-7d
| eval
user_id=coalesce(user_id, UserId),
src=coalesce(src, IPAddress),
method=upper(coalesce(method, RequestMethod)),
uri=coalesce(uri, RequestUri),
status=coalesce(status, ResponseStatusCode),
app_id=coalesce(app_id, AppId),
session_id=coalesce(session_id, SessionId),
user_agent=coalesce(user_agent, UserAgent)
| where user_id="<candidate_user_object_id>" AND method="GET" AND status>=200 AND status<300
| eval endpoint_family=case(
like(uri,"%/servicePrincipals%"),"servicePrincipals",
like(uri,"%/applications%"),"applications",
like(uri,"%/roleManagement/directory%"),"roleManagement",
like(uri,"%/directoryRoles%"),"directoryRoles",
like(uri,"%/users%"),"users",
true(),"other"
)
| where endpoint_family!="other"
| stats
min(_time) as first_seen
max(_time) as last_seen
count as requests
dc(endpoint_family) as endpoint_families
values(endpoint_family) as families
values(uri) as sample_uris
by user_id src app_id session_id user_agent
| where requests>=20 AND endpoint_families>=3
| convert ctime(first_seen) ctime(last_seen)
| sort - requestsWhat to look for
Directory enumeration from the candidate user/session that is inconsistent with normal role or automation behavior.
Technical details
Tested signal
Successful Microsoft Graph GET burst across several directory-discovery endpoint families.
Assumptions
- Microsoft Graph Activity Logs are enabled.
- The candidate's Entra user object ID is known.
Data requirements and relevant fields
- cloud control plane
Microsoft Graph Activity Logs with caller identity, request URI/method, response status, source IP, app, session, and token context.
TimeGeneratedAppIdClientAuthMethodDeviceIdIPAddressOperationIdRequestIdRequestMethodRequestUriResponseSizeBytesResponseStatusCodeRolesScopesServicePrincipalIdSessionIdSignInActivityIdTokenIssuedAtUniqueTokenIdUserAgentUserIdWids
KQL schema
Validate connector availability, nested-field shape, retention, and licensing before operational use.
SPL schema
Replace index/sourcetype placeholders and map the documented identity, Graph, and Azure control-plane concepts to local fields.
Limitations
- IAM automation and legitimate admin tools can perform similar enumeration.
KQL uses Microsoft Entra / Azure Monitor telemetry. SPL is a normalized raw-event scaffold and requires local field mapping.
View query — Check application and service-principal persistence attempts
Q-05DrilldownCheck application and service-principal persistence attempts
What this checks
Find credential-management activity against applications/service principals initiated by the candidate identity.
KQL
let target_user = "<candidate_user>";
AuditLogs
| where TimeGenerated >= ago(7d)
| where Category =~ "ApplicationManagement"
| where OperationName has_any (
"Add service principal",
"Certificates and secrets management"
)
| extend
InitiatingUser = tolower(tostring(InitiatedBy.user.userPrincipalName)),
InitiatingIP = tostring(InitiatedBy.user.ipAddress)
| where InitiatingUser == tolower(target_user)
| mv-apply TargetResource = TargetResources on (
where TargetResource.type in~ ("Application", "ServicePrincipal")
| extend
TargetName = tostring(TargetResource.displayName),
TargetId = tostring(TargetResource.id),
TargetType = tostring(TargetResource.type),
ModifiedProperties = TargetResource.modifiedProperties
)
| project
TimeGenerated,
OperationName,
Result,
ResultReason,
InitiatingUser,
InitiatingIP,
TargetName,
TargetId,
TargetType,
ModifiedProperties,
CorrelationId
| order by TimeGenerated ascSPL
index=<entra_audit_index> sourcetype=<entra_audit_sourcetype>
earliest=-7d
(
OperationName="Add service principal credentials"
OR OperationName="Add service principal"
OR OperationName="Update application - Certificates and secrets management"
OR OperationName="Certificates and secrets management"
)
| eval
user=lower(coalesce(user, InitiatingUserPrincipalName, userPrincipalName)),
src=coalesce(src, InitiatingIpAddress, ipAddress),
operation=coalesce(operation, OperationName, ActivityDisplayName),
result=lower(coalesce(result, Result)),
target_name=coalesce(target_name, TargetResourceDisplayName),
target_id=coalesce(target_id, TargetResourceId),
target_type=coalesce(target_type, TargetResourceType),
correlation_id=coalesce(correlation_id, CorrelationId)
| where user="<candidate_user>"
| fields _time user src operation result ResultReason target_name target_id target_type correlation_id ModifiedProperties
| sort 0 _timeWhat to look for
Successful or failed credential-management actions against application identities.
Technical details
Tested signal
Application or service-principal credential changes after SSPR takeover.
Assumptions
- The candidate user from Q-01 is known.
Data requirements and relevant fields
- identity
Microsoft Entra audit events for self-service password reset and authentication-method changes.
TimeGeneratedLoggedByServiceCategoryOperationNameResultResultReasonInitiatedByTargetResourcesAdditionalDetailsCorrelationId
KQL schema
Validate connector availability, nested-field shape, retention, and licensing before operational use.
SPL schema
Replace index/sourcetype placeholders and map the documented identity, Graph, and Azure control-plane concepts to local fields.
Limitations
- Legitimate app owners and administrators rotate secrets/certificates.
KQL uses Microsoft Entra / Azure Monitor telemetry. SPL is a normalized raw-event scaffold and requires local field mapping.
Legitimate resemblance
What the analyst should confirm
A user legitimately resets a forgotten password, replaces a lost phone, and signs in from a new travel or home network.
The user confirms the recovery, the new MFA device is expected, the source aligns with travel/VPN context, and no unusual Graph or cloud-control activity follows.IT performs a documented identity-recovery workflow for a user who lost all registered authentication methods.
The helpdesk ticket, named operator, recovery timestamps, trusted source locations, replacement device, and final user confirmation align.A privileged cloud engineer recovers an account and then runs normal Graph inventory/admin tooling.
The Graph app ID, user agent, endpoint families, source network, automation ownership, and change record match the approved workflow.
Confirmed match
Action after a confirmed match
- Contain the compromised identity and revoke sessions/tokens.
- Reset the password through a trusted recovery channel.
- Remove attacker-controlled authentication methods and re-register trusted methods.
- Review application and service-principal credentials added or modified by the identity.
- Scope Microsoft Graph directory discovery and all high-value Azure operations after takeover.
- Rotate exposed secrets, publishing credentials, storage keys, or database credentials.
- Require phishing-resistant MFA for privileged users and harden SSPR/helpdesk recovery.
Threat hunt
Could this be happening elsewhere?
Hunt for this behavior across the environment.Technical boundary
Telemetry and limitations
- Identity
Microsoft Entra audit events for self-service password reset and authentication-method changes.
Required fieldsTimeGeneratedLoggedByServiceCategoryOperationNameResultResultReasonInitiatedByTargetResourcesAdditionalDetailsCorrelationId
- Authentication
Microsoft Entra sign-in events with source, app/resource, device, risk, and session/correlation context.
Required fieldsTimeGeneratedUserPrincipalNameUserIdIPAddressAppDisplayNameAppIdResourceDisplayNameResultTypeResultDescriptionConditionalAccessStatusAuthenticationRequirementRiskLevelDuringSignInRiskLevelAggregatedRiskStateDeviceDetailUserAgentAutonomousSystemNumberCorrelationIdSessionId
- Cloud Control Plane
Microsoft Graph Activity Logs with caller identity, request URI/method, response status, source IP, app, session, and token context.
Required fieldsTimeGeneratedAppIdClientAuthMethodDeviceIdIPAddressOperationIdRequestIdRequestMethodRequestUriResponseSizeBytesResponseStatusCodeRolesScopesServicePrincipalIdSessionIdSignInActivityIdTokenIssuedAtUniqueTokenIdUserAgentUserIdWids
Blind spots
- Tenants that do not export SSPR and authentication-method audit events cannot reconstruct the full recovery sequence.
- A compromised user who already has common device/network patterns can avoid the first-seen source condition.
- Graph Activity Logs must be explicitly available to support request-level discovery analysis.
- Legitimate account recovery can look very similar until user/helpdesk context is obtained.
- The source-IP baseline can be noisy in organizations with large VPN, SWG, VDI, or mobile populations.
Behavior mapping
MITRE ATT&CK
T1078.004· Cloud AccountsThe attacker operates through the compromised Microsoft Entra cloud identity after recovery abuse.
T1098.005· Device RegistrationAuthentication-method replacement creates attacker-controlled MFA persistence.
T1087.004· Cloud AccountGraph enumeration is used as a follow-on drilldown for cloud-account discovery.
T1098.001· Additional Cloud CredentialsApplication/service-principal credential changes are reviewed as cloud persistence attempts.
Mappings describe the behavior examined by this analytic. They do not prove attribution, deployment, or technique-wide coverage.
Review boundary
Sources and limits
- External sources
- 6
Exact fields, retention, and operational thresholds remain environment-specific.