Detection rules › Elastic

Potential Okta Password Spray (Multi-Source)

Status
production
Severity
medium
Time window
1h
Group by
Esql.time_bucket
Author
Elastic
Source
github.com/elastic/detection-rules

Detects potential password spray attacks where multiple source IPs target multiple Okta user accounts within a time window, indicating coordinated attacks using IP rotation to evade single-source detection.

MITRE ATT&CK coverage

TacticTechniques
Credential AccessT1110.003 Brute Force: Password Spraying

Event coverage

Rules detecting the same action

Other rules on this platform that filter on the same API call or operation.

Rule body elastic

[metadata]
creation_date = "2026/02/19"
integration = ["okta"]
maturity = "production"
updated_date = "2026/04/10"

[rule]
author = ["Elastic"]
description = """
Detects potential password spray attacks where multiple source IPs target multiple Okta user accounts within a
time window, indicating coordinated attacks using IP rotation to evade single-source detection.
"""
false_positives = [
    "Large enterprises with many users experiencing simultaneous password issues during credential rotation events.",
    "Automated monitoring or penetration testing tools scanning from multiple IPs.",
]
from = "now-1h"
interval = "15m"
language = "esql"
license = "Elastic License v2"
name = "Potential Okta Password Spray (Multi-Source)"
note = """## Triage and analysis

### Investigating Potential Okta Password Spray (Multi-Source)

This rule identifies coordinated password spray attacks where multiple source IPs target multiple user accounts within a time window. This pattern indicates attackers using IP rotation to evade single-source detection while spraying passwords across the organization.

#### Possible investigation steps
- Review the list of targeted user accounts and check if any authentications succeeded.
- Examine the source IPs and their ASN ownership for signs of proxy, VPN, or cloud infrastructure.
- Check if Okta flagged any of the sources as known threats or proxies.
- Analyze the attempts-per-user ratio to confirm spray behavior versus brute force.
- Review the geographic distribution of source IPs for coordination patterns.
- Cross-reference with successful authentication events to identify potential compromises.

### False positive analysis
- Organization-wide password rotation or expiration events may cause widespread authentication failures.
- Misconfigured SSO or SAML integrations can cause batch failures from legitimate infrastructure.
- Penetration testing should be coordinated and whitelisted in advance.

### Response and remediation
- If attack is confirmed, notify affected users and enforce password resets for potentially compromised accounts.
- Block attacking IP ranges at the network perimeter.
- Enable or strengthen MFA for targeted accounts.
- Review Okta sign-on policies to add additional friction for suspicious authentication patterns.
- Consider temporary lockdowns for highly targeted accounts.
"""
references = [
    "https://support.okta.com/help/s/article/Troubleshooting-Distributed-Brute-Force-andor-Password-Spray-attacks-in-Okta",
    "https://www.okta.com/identity-101/brute-force/",
    "https://developer.okta.com/docs/reference/api/event-types/",
    "https://www.elastic.co/security-labs/testing-okta-visibility-and-detection-dorothy",
    "https://www.elastic.co/security-labs/monitoring-okta-threats-with-elastic-security",
    "https://www.elastic.co/security-labs/starter-guide-to-understanding-okta",
]
risk_score = 47
rule_id = "2d3c27d5-d133-4152-8102-8d051619ec4a"
setup = "The Okta Fleet integration, Filebeat module, or similarly structured data is required to be compatible with this rule."
severity = "medium"
tags = [
    "Domain: Identity",
    "Use Case: Identity and Access Audit",
    "Use Case: Threat Detection",
    "Data Source: Okta",
    "Data Source: Okta System Logs",
    "Tactic: Credential Access",
    "Resources: Investigation Guide",
]
timestamp_override = "event.ingested"
type = "esql"

query = '''
FROM logs-okta.system-* METADATA _id, _version, _index
| WHERE
    data_stream.dataset == "okta.system"
    AND (event.action LIKE "user.authentication.*" OR event.action == "user.session.start")
    AND okta.outcome.reason IN ("INVALID_CREDENTIALS", "LOCKED_OUT")
    AND okta.actor.alternate_id IS NOT NULL

// Bucket into 15-minute windows and create user-source mapping for context
| EVAL
    Esql.time_bucket = DATE_TRUNC(15 minutes, @timestamp),
    Esql.user_source_info = CONCAT(
      "{\"user\":\"", okta.actor.alternate_id,
      "\",\"ip\":\"", COALESCE(okta.client.ip::STRING, "unknown"),
      "\",\"user_agent\":\"", COALESCE(okta.client.user_agent.raw_user_agent, "unknown"), "\"}"
    )

// Aggregate across entire tenant per time bucket to detect distributed spray
| STATS
    Esql.unique_users = COUNT_DISTINCT(okta.actor.alternate_id),
    Esql.unique_source_ips = COUNT_DISTINCT(okta.client.ip),
    Esql.total_attempts = COUNT(*),
    Esql.unique_user_agents = COUNT_DISTINCT(okta.client.user_agent.raw_user_agent),
    Esql.unique_asns = COUNT_DISTINCT(source.as.number),
    Esql.unique_countries = COUNT_DISTINCT(client.geo.country_name),
    Esql.first_seen = MIN(@timestamp),
    Esql.last_seen = MAX(@timestamp),
    Esql.target_users = VALUES(okta.actor.alternate_id),
    Esql.source_ip_values = VALUES(okta.client.ip),
    Esql.user_source_mapping = VALUES(Esql.user_source_info),
    Esql.event_action_values = VALUES(event.action),
    Esql.user_agent_values = VALUES(okta.client.user_agent.raw_user_agent),
    Esql.device_values = VALUES(okta.client.device),
    Esql.is_proxy_values = VALUES(okta.security_context.is_proxy),
    Esql.geo_country_values = VALUES(client.geo.country_name),
    Esql.geo_city_values = VALUES(client.geo.city_name),
    Esql.source_asn_values = VALUES(source.as.number),
    Esql.source_asn_org_values = VALUES(source.as.organization.name),
    Esql.threat_suspected_values = VALUES(okta.debug_context.debug_data.threat_suspected),
    Esql.risk_level_values = VALUES(okta.debug_context.debug_data.risk_level),
    Esql.risk_reasons_values = VALUES(okta.debug_context.debug_data.risk_reasons)
  BY Esql.time_bucket

// Calculate spray metrics
| EVAL
    Esql.attempts_per_user = Esql.total_attempts * 1.0 / Esql.unique_users,
    Esql.attempts_per_ip = Esql.total_attempts * 1.0 / Esql.unique_source_ips,
    Esql.users_per_ip = Esql.unique_users * 1.0 / Esql.unique_source_ips

// Distributed spray: many IPs, many users, moderate spread across both
// Key differentiator: attacks come from multiple IPs (evading per-IP rules)
| WHERE
    Esql.unique_source_ips >= 5
    AND Esql.unique_users >= 8
    AND Esql.total_attempts >= 25
    AND Esql.attempts_per_user <= 5.0
    AND Esql.users_per_ip >= 1.0

| SORT Esql.total_attempts DESC
| KEEP Esql.*
'''


[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1110"
name = "Brute Force"
reference = "https://attack.mitre.org/techniques/T1110/"
[[rule.threat.technique.subtechnique]]
id = "T1110.003"
name = "Password Spraying"
reference = "https://attack.mitre.org/techniques/T1110/003/"


[rule.threat.tactic]
id = "TA0006"
name = "Credential Access"
reference = "https://attack.mitre.org/tactics/TA0006/"

Stages and Predicates

Stage 1: from

FROM logs-okta.system-* METADATA _id, _version, _index

Stage 2: where

| WHERE
    data_stream.dataset == "okta.system"
    AND (event.action LIKE "user.authentication.*" OR event.action == "user.session.start")
    AND okta.outcome.reason IN ("INVALID_CREDENTIALS", "LOCKED_OUT")
    AND okta.actor.alternate_id IS NOT NULL

Stage 3: eval

| EVAL
    Esql.time_bucket = DATE_TRUNC(15 minutes, @timestamp),
    Esql.user_source_info = CONCAT(
      "{\"user\":\"", okta.actor.alternate_id,
      "\",\"ip\":\"", COALESCE(okta.client.ip::STRING, "unknown"),
      "\",\"user_agent\":\"", COALESCE(okta.client.user_agent.raw_user_agent, "unknown"), "\"}"
    )

Stage 4: stats

| STATS
    Esql.unique_users = COUNT_DISTINCT(okta.actor.alternate_id),
    Esql.unique_source_ips = COUNT_DISTINCT(okta.client.ip),
    Esql.total_attempts = COUNT(*),
    Esql.unique_user_agents = COUNT_DISTINCT(okta.client.user_agent.raw_user_agent),
    Esql.unique_asns = COUNT_DISTINCT(source.as.number),
    Esql.unique_countries = COUNT_DISTINCT(client.geo.country_name),
    Esql.first_seen = MIN(@timestamp),
    Esql.last_seen = MAX(@timestamp),
    Esql.target_users = VALUES(okta.actor.alternate_id),
    Esql.source_ip_values = VALUES(okta.client.ip),
    Esql.user_source_mapping = VALUES(Esql.user_source_info),
    Esql.event_action_values = VALUES(event.action),
    Esql.user_agent_values = VALUES(okta.client.user_agent.raw_user_agent),
    Esql.device_values = VALUES(okta.client.device),
    Esql.is_proxy_values = VALUES(okta.security_context.is_proxy),
    Esql.geo_country_values = VALUES(client.geo.country_name),
    Esql.geo_city_values = VALUES(client.geo.city_name),
    Esql.source_asn_values = VALUES(source.as.number),
    Esql.source_asn_org_values = VALUES(source.as.organization.name),
    Esql.threat_suspected_values = VALUES(okta.debug_context.debug_data.threat_suspected),
    Esql.risk_level_values = VALUES(okta.debug_context.debug_data.risk_level),
    Esql.risk_reasons_values = VALUES(okta.debug_context.debug_data.risk_reasons)
  BY Esql.time_bucket

Stage 5: eval

| EVAL
    Esql.attempts_per_user = Esql.total_attempts * 1.0 / Esql.unique_users,
    Esql.attempts_per_ip = Esql.total_attempts * 1.0 / Esql.unique_source_ips,
    Esql.users_per_ip = Esql.unique_users * 1.0 / Esql.unique_source_ips

Stage 6: where

| WHERE
    Esql.unique_source_ips >= 5
    AND Esql.unique_users >= 8
    AND Esql.total_attempts >= 25
    AND Esql.attempts_per_user <= 5.0
    AND Esql.users_per_ip >= 1.0

Stage 7: sort

| SORT Esql.total_attempts DESC

Stage 8: keep

| KEEP Esql.*

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
Esql.attempts_per_userle
  • 5.0
Esql.total_attemptsge
  • 25
Esql.unique_source_ipsge
  • 5
Esql.unique_usersge
  • 8
Esql.users_per_ipge
  • 1.0
data_stream.dataseteq
  • okta.system
event.actioneq
  • user.session.start
event.actionwildcard
  • user.authentication.*
okta.actor.alternate_idis_not_null
  • (no value, null check)
okta.outcome.reasonin
  • INVALID_CREDENTIALS
  • LOCKED_OUT

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
Esql.*KEEP Esql.*