← All projects Identity and detection

Okta in Terraform, and a detection I tested both ways

Okta · Terraform · Elastic · Sigma

What this demonstrates: Identity as code and tested detections: a fake company's Okta directory managed in Terraform from an HR file, a log reader with one read-only scope, and a detection tested for when it should fire and when it shouldn't.

Homelab project: a fake company, real systems, my own test traffic.

Problem

Most attacks on a company go through its identity provider. I wanted my fake company's Okta setup to be reviewable. When someone adds a user or changes a group by hand, nobody reviews it and there's no record of why.

I also wanted my first detection to be tested. A rule that has never run against real events can miss the attack. It can also fire so often that people stop reading it.

What I built

Identity as code to detectionAn HR CSV drives Terraform, which manages the Okta directory. Okta's audit log is read by Elastic Agent through a read-only OAuth app and stored in Elasticsearch, where a threshold rule built from a Sigma query raises an alert. HR file (CSV) source of truth: 30 employees terraform plan → review → apply nothing applied without review Okta directory 8 departments · 13 groups users staged until needed Elastic Agent its own OAuth app one scope: read logs Elasticsearch each event stored by its Okta ID Threshold rule Sigma base query 5+ failures, one user, 5 minutes Alert one per burst, with the count edit a row token: home network only audit log, polled every 60 s send query fires
How a change flows. The HR file drives the directory, the directory's audit log goes to the SIEM, and the rule turns failures into alerts. The highlighted boxes are the controls.

The directory has 30 fake employees in 8 departments and 13 groups. A CSV file plays the HR system. To add or remove someone, I edit the CSV, run terraform plan, read the plan and then apply it. If anyone changes Okta by hand, the next plan shows the difference.

The Terraform API token only works from my home network. Admin sign-in uses Okta push with number matching, so an attacker can't send a stream of push prompts and hope I tap yes.

Okta's audit log goes into Elastic through Elastic Agent. The agent signs in as its own OAuth app with one scope, okta.logs.read. I didn't reuse the Terraform token for this, because that token can create and change users.

The rule looks for 5 or more failed sign-ins for one user within 5 minutes, which suggests someone is guessing a password (MITRE ATT&CK T1110.001). I wrote the base query in Sigma and converted it for Elastic. The count lives in the Elastic rule. I tested two Sigma backends, and neither could produce a Kibana threshold rule.

The converter's output is the deployed querySigma rule → Lucene, run with the field-mapping pipeline
$ sigma convert -t lucene -p detections/pipelines/okta-ecs.yml \
    detections/okta/okta_repeated_failed_logins.yml
event.dataset:okta.system AND (event.action:user.session.start AND event.outcome:failure)

# Elastic threshold rule on top of that query:
#   group by   user.name
#   threshold  >= 5
#   runs every 5 minutes, looks back 6 minutes

Exact command and output, from the rule file's header. The threshold settings are summarized from the Elastic rule.

A script deploys the rule with an API key that can only manage detection rules. The key expires after 90 days.

How I tested it

Test cases next to the rule

I keep test cases in a file next to the rule. Each case says whether the rule should fire, and why.

Test cases file: why each case should or shouldn't firedetections/okta/tests/*.cases.json (made-up events, real shape)
fire     five failures for one user in four minutes
         5 matching events, same user.name

no_fire  four failures, then a success (forgot the password)
         only 4 failures; the success doesn't match

no_fire  five failures spread over five users (spraying)
         1 per user: a known gap, spraying needs
         its own rule, grouped by source.ip

events in each case look like:
{"event": {"dataset": "okta.system", "action": "user.session.start",
           "outcome": "failure"},
 "user": {"name": "user-a@example-company"},
 "okta": {"outcome": {"reason": "VERIFICATION_ERROR"}},
 "source": {"ip": "<documentation range>"}}

The case names, expectations, and reasons come from the file. The events already use made-up users and documentation IP ranges. Here they're shortened, and the users and IPs are replaced.

The third case is a gap I know about. Password spraying tries many users once each, so a per-user count never reaches 5. Catching it needs a separate rule that groups by source IP.

Live test with real Okta events

Then I ran it against real sign-ins with two test users. User A failed 6 times in 31 seconds and got one alert with a count of 6. User B failed 4 times and got no alert. A few minutes later, user A failed 7 more times and got a second alert with a count of 7. The rule counted the two bursts separately.

Replay: the live failed-sign-in testReal times and counts (PT, 2026-10-04). Users renamed; alert text rebuilt.
  1. 4:18:30 PM · step 1 of 7

    Burst 1: user A fails 6 times

    Six failed sign-ins in 31 seconds. Okta logs each one as a failed user.session.start.

    4:18:30 → 4:19:01   user A   user.session.start   FAILURE   x6
                         reason: VERIFICATION_ERROR
    ← Back
  2. 4:19:23 PM · step 2 of 7

    User B fails 4 times

    Four failures in 11 seconds, one below the threshold. This is the forgot-my-password shape.

    4:19:23 → 4:19:34   user B   user.session.start   FAILURE   x4
  3. +71 to 115 s · step 3 of 7

    The events reach the SIEM

    Each event is ingested 71 to 115 seconds after it happened. That's the polling interval plus delivery. The rule's 5-minute window has to allow for this.

  4. ~4:25 PM · step 4 of 7

    Alert 1: user A, count 6

    The threshold rule fires once for user A. User B has no alert, which is the expected result.

    ALERT  Okta: repeated failed sign-ins for one user
           user: user A   count: 6
    (no alert for user B: 4 < 5)
  5. 4:26:07 PM · step 5 of 7

    Burst 2: user A fails 7 more times

    A second burst for the same user, 4:26:07 to 4:26:23.

  6. after 4:30 PM · step 6 of 7

    Alert 2: user A, count 7

    A repeat burst alerts again. The two bursts are counted separately (6 and 7), not as 13.

    ALERT  Okta: repeated failed sign-ins for one user
           user: user A   count: 7
  7. what it doesn't show · step 7 of 7

    What this test doesn't show

    Both users were staged, so these were "can't sign in" failures, not real wrong passwords, and no lockout was possible. Testing a real wrong password and a lockout is next.

Both test users were staged, so Okta saw them as users who can't sign in. These weren't real wrong passwords, and no lockout could happen. Testing a real wrong password and a lockout is my next step.

Revoking the collector's access

I also tested what happens when the collector loses access. I deactivated its Okta app, and Okta refused it 2 seconds later. When I turned the app back on, access didn't come back. Deactivating had removed its admin role and its scope grant. The admin console still showed the scope as granted, but Okta's own log showed it wasn't.

After I restored both, the collector picked up from its bookmark. It caught up on 31 events with no gaps and no duplicates. I wrote a recovery checklist from what I found.

What I learned