Detection rules › Elastic

Potential Okta Password Spray (Single Source)

Status
production
Severity
medium
Time window
1h
Group by
okta.actor.alternate_id, okta.client.ip
Author
Elastic
Source
github.com/elastic/detection-rules

Detects potential password spray attacks where a single source IP attempts authentication against multiple Okta user accounts with repeated attempts per user, indicating common password guessing paced to avoid lockouts.

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 = "2020/07/16"
integration = ["okta"]
maturity = "production"
updated_date = "2026/04/10"

[rule]
author = ["Elastic"]
description = """
Detects potential password spray attacks where a single source IP attempts authentication against multiple Okta
user accounts with repeated attempts per user, indicating common password guessing paced to avoid lockouts.
"""
false_positives = [
    "Corporate proxy or VPN exit nodes may aggregate traffic from multiple legitimate users with login issues.",
    "Automated processes or misconfigured applications retrying authentication may trigger this rule.",
]
from = "now-1h"
interval = "15m"
language = "esql"
license = "Elastic License v2"
name = "Potential Okta Password Spray (Single Source)"
note = """## Triage and analysis

### Investigating Potential Okta Password Spray (Single Source)

This rule identifies a single source IP attempting authentication against multiple user accounts with repeated attempts per user over time. This pattern indicates password spraying where attackers try common passwords while pacing attempts to avoid lockouts.

#### Possible investigation steps
- Identify the source IP and determine if it belongs to known proxy, VPN, or cloud infrastructure.
- Review the list of targeted user accounts and check if any authentications succeeded.
- Analyze the timing of attempts to determine if they are paced to avoid lockout thresholds.
- Check if Okta flagged the source as a known threat or proxy.
- Examine user agent strings for signs of automation or consistent tooling across attempts.
- Review the geographic location and ASN of the source IP for anomalies.

### False positive analysis
- Corporate proxies or VPN exit nodes may aggregate traffic from multiple legitimate users with login issues.
- Automated processes or misconfigured applications retrying authentication may trigger this rule.
- Password rotation events may cause legitimate widespread authentication failures.

### Response and remediation
- If attack is confirmed, block the source IP at the network perimeter.
- Notify targeted users and enforce password resets for accounts that may have been compromised.
- Enable or strengthen MFA for targeted accounts.
- Consider implementing CAPTCHA or additional friction for suspicious authentication patterns.
- Review Okta sign-on policies to ensure lockout thresholds are appropriately configured.
"""
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/system-log/",
    "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 = "42bf698b-4738-445b-8231-c834ddefd8a0"
severity = "medium"
tags = [
    "Domain: Identity",
    "Use Case: Identity and Access Audit",
    "Tactic: Credential Access",
    "Data Source: Okta",
    "Data Source: Okta System Logs",
    "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
// Build user-source context as JSON for enrichment
| EVAL 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"), "\"}"
  )
// FIRST STATS: Aggregate by (IP, user) to get per-user attempt counts
// This prevents skew from outlier users with many attempts
| STATS
    Esql.user_attempts = COUNT(*),
    Esql.user_source_info = VALUES(Esql.user_source_info),
    Esql.user_agents_per_user = VALUES(okta.client.user_agent.raw_user_agent),
    Esql.devices_per_user = VALUES(okta.client.device),
    Esql.is_proxy = VALUES(okta.security_context.is_proxy),
    Esql.geo_country = VALUES(client.geo.country_name),
    Esql.geo_city = VALUES(client.geo.city_name),
    Esql.asn_number = VALUES(source.as.number),
    Esql.asn_org = VALUES(source.as.organization.name),
    Esql.threat_suspected = VALUES(okta.debug_context.debug_data.threat_suspected),
    Esql.risk_level = VALUES(okta.debug_context.debug_data.risk_level),
    Esql.event_actions = VALUES(event.action),
    Esql.first_seen_user = MIN(@timestamp),
    Esql.last_seen_user = MAX(@timestamp)
  BY okta.client.ip, okta.actor.alternate_id
// SECOND STATS: Aggregate by IP to detect password spray pattern
// Now we can accurately measure the distribution of attempts across users
| STATS
    Esql.unique_users = COUNT(*),
    Esql.total_attempts = SUM(Esql.user_attempts),
    Esql.max_attempts_per_user = MAX(Esql.user_attempts),
    Esql.min_attempts_per_user = MIN(Esql.user_attempts),
    Esql.avg_attempts_per_user = AVG(Esql.user_attempts),
    // Spray band: 2-6 attempts per user (deliberate slow spray below lockout)
    Esql.users_in_spray_band = SUM(CASE(Esql.user_attempts >= 2 AND Esql.user_attempts <= 6, 1, 0)),
    // Also track users with only 1 attempt (stuffing-like) for differentiation
    Esql.users_with_single_attempt = SUM(CASE(Esql.user_attempts == 1, 1, 0)),
    Esql.first_seen = MIN(Esql.first_seen_user),
    Esql.last_seen = MAX(Esql.last_seen_user),
    Esql.target_users = VALUES(okta.actor.alternate_id),
    Esql.user_source_mapping = VALUES(Esql.user_source_info),
    Esql.event_action_values = VALUES(Esql.event_actions),
    Esql.user_agent_values = VALUES(Esql.user_agents_per_user),
    Esql.device_values = VALUES(Esql.devices_per_user),
    Esql.is_proxy_values = VALUES(Esql.is_proxy),
    Esql.geo_country_values = VALUES(Esql.geo_country),
    Esql.geo_city_values = VALUES(Esql.geo_city),
    Esql.source_asn_values = VALUES(Esql.asn_number),
    Esql.source_asn_org_values = VALUES(Esql.asn_org),
    Esql.threat_suspected_values = VALUES(Esql.threat_suspected),
    Esql.risk_level_values = VALUES(Esql.risk_level)
  BY okta.client.ip
// Calculate spray signature metrics
| EVAL
    // Percentage of users in the spray band (2-6 attempts)
    Esql.pct_users_in_spray_band = Esql.users_in_spray_band * 100.0 / Esql.unique_users,
    // Attack duration in minutes (spray is paced, not bursty)
    Esql.attack_duration_minutes = DATE_DIFF("minute", Esql.first_seen, Esql.last_seen)
// Password spraying detection logic:
// - Many users targeted (>= 5)
// - Hard cap below Okta lockout threshold (max <= 8 attempts per user)
// - Majority of users in spray band (2-6 attempts) (at least 60%)
// - Attack is paced over time (>= 5 minutes) (not a 10-second burst like stuffing)
// - Minimum total attempts to reduce noise
// Note: For IP rotation attacks, see "Distributed Password Spray Attack in Okta" rule
| WHERE
    Esql.unique_users >= 5
    AND Esql.total_attempts >= 15
    AND Esql.max_attempts_per_user <= 8
    AND Esql.max_attempts_per_user >= 2
    AND Esql.pct_users_in_spray_band >= 60.0
    AND Esql.attack_duration_minutes >= 5
| SORT Esql.total_attempts DESC
| KEEP Esql.*, okta.client.ip
'''


[[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.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.user_attempts = COUNT(*),
    Esql.user_source_info = VALUES(Esql.user_source_info),
    Esql.user_agents_per_user = VALUES(okta.client.user_agent.raw_user_agent),
    Esql.devices_per_user = VALUES(okta.client.device),
    Esql.is_proxy = VALUES(okta.security_context.is_proxy),
    Esql.geo_country = VALUES(client.geo.country_name),
    Esql.geo_city = VALUES(client.geo.city_name),
    Esql.asn_number = VALUES(source.as.number),
    Esql.asn_org = VALUES(source.as.organization.name),
    Esql.threat_suspected = VALUES(okta.debug_context.debug_data.threat_suspected),
    Esql.risk_level = VALUES(okta.debug_context.debug_data.risk_level),
    Esql.event_actions = VALUES(event.action),
    Esql.first_seen_user = MIN(@timestamp),
    Esql.last_seen_user = MAX(@timestamp)
  BY okta.client.ip, okta.actor.alternate_id

Stage 5: stats

| STATS
    Esql.unique_users = COUNT(*),
    Esql.total_attempts = SUM(Esql.user_attempts),
    Esql.max_attempts_per_user = MAX(Esql.user_attempts),
    Esql.min_attempts_per_user = MIN(Esql.user_attempts),
    Esql.avg_attempts_per_user = AVG(Esql.user_attempts),
    Esql.users_in_spray_band = SUM(CASE(Esql.user_attempts >= 2 AND Esql.user_attempts <= 6, 1, 0)),
    Esql.users_with_single_attempt = SUM(CASE(Esql.user_attempts == 1, 1, 0)),
    Esql.first_seen = MIN(Esql.first_seen_user),
    Esql.last_seen = MAX(Esql.last_seen_user),
    Esql.target_users = VALUES(okta.actor.alternate_id),
    Esql.user_source_mapping = VALUES(Esql.user_source_info),
    Esql.event_action_values = VALUES(Esql.event_actions),
    Esql.user_agent_values = VALUES(Esql.user_agents_per_user),
    Esql.device_values = VALUES(Esql.devices_per_user),
    Esql.is_proxy_values = VALUES(Esql.is_proxy),
    Esql.geo_country_values = VALUES(Esql.geo_country),
    Esql.geo_city_values = VALUES(Esql.geo_city),
    Esql.source_asn_values = VALUES(Esql.asn_number),
    Esql.source_asn_org_values = VALUES(Esql.asn_org),
    Esql.threat_suspected_values = VALUES(Esql.threat_suspected),
    Esql.risk_level_values = VALUES(Esql.risk_level)
  BY okta.client.ip

Stage 6: eval

| EVAL
    Esql.pct_users_in_spray_band = Esql.users_in_spray_band * 100.0 / Esql.unique_users,
    Esql.attack_duration_minutes = DATE_DIFF("minute", Esql.first_seen, Esql.last_seen)

Stage 7: where

| WHERE
    Esql.unique_users >= 5
    AND Esql.total_attempts >= 15
    AND Esql.max_attempts_per_user <= 8
    AND Esql.max_attempts_per_user >= 2
    AND Esql.pct_users_in_spray_band >= 60.0
    AND Esql.attack_duration_minutes >= 5

Stage 8: sort

| SORT Esql.total_attempts DESC

Stage 9: keep

| KEEP Esql.*, okta.client.ip

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.attack_duration_minutesge
  • 5
Esql.max_attempts_per_userge
  • 2
Esql.max_attempts_per_userle
  • 8
Esql.pct_users_in_spray_bandge
  • 60.0
Esql.total_attemptsge
  • 15
Esql.unique_usersge
  • 5
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.*
okta.client.ipKEEP okta.client.ip