How to Detect Account Takeover in Real Time

How to Detect Account Takeover in Real Time

How to Detect Account Takeover in Real Time

Account takeover (ATO) is the endpoint of most credential theft campaigns. Attackers buy credential dumps, run automated stuffing against login endpoints, and pivot the successful logins into fraud within minutes. A detection system that runs batch jobs overnight catches nothing useful. You need streaming correlation across login, transaction, and device signals with sub-second latency.

RisingWave, an open-source PostgreSQL-compatible streaming database, lets you write ATO detection logic in plain SQL. The database reads from Kafka, continuously maintains materialized views over the event streams, and serves risk scores through its PostgreSQL interface -- no Java, no Flink cluster, no separate OLAP database.

What signals indicate account takeover?

ATO attacks leave three categories of signal: authentication anomalies, behavioral drift, and geographic impossibilities.

Authentication anomalies are the most direct. A burst of failed logins followed immediately by a success is the signature of a credential stuffing attack. Logins from newly registered devices or IP ranges associated with datacenter ASNs (indicating automation or proxies) are secondary confirmation.

Behavioral drift compares the current session to the user's historical pattern. An account that normally logs in from a single city during business hours and suddenly initiates a wire transfer at 3 AM from a mobile device is showing drift across multiple dimensions simultaneously.

Geographic impossibilities are the clearest signal. Two logins separated by more than 900 km/h of implied travel speed cannot both be the legitimate account owner.

How to build ATO detection with RisingWave

The architecture uses three Kafka topics as sources: login_events, transactions, and device_fingerprints. RisingWave ingests all three and correlates them through materialized views.

Step 1: Create the sources

CREATE SOURCE login_events (
    event_id      VARCHAR,
    user_id       VARCHAR,
    ip_address    VARCHAR,
    latitude      DOUBLE,
    longitude     DOUBLE,
    device_id     VARCHAR,
    success       BOOLEAN,
    event_time    TIMESTAMPTZ
) WITH (
    connector = 'kafka',
    topic = 'login_events',
    properties.bootstrap.server = 'kafka:9092',
    scan.startup.mode = 'latest'
) FORMAT PLAIN ENCODE JSON;

CREATE SOURCE transactions (
    txn_id        VARCHAR,
    user_id       VARCHAR,
    amount        DECIMAL,
    merchant_id   VARCHAR,
    txn_time      TIMESTAMPTZ
) WITH (
    connector = 'kafka',
    topic = 'transactions',
    properties.bootstrap.server = 'kafka:9092',
    scan.startup.mode = 'latest'
) FORMAT PLAIN ENCODE JSON;

Step 2: Detect credential stuffing with a tumbling window

A credential stuffing burst produces many failures in a short window before a success. Count failures and flag accounts where the failure rate in the last 10 minutes exceeds 5.

CREATE MATERIALIZED VIEW login_failure_rate AS
SELECT
    user_id,
    window_start,
    window_end,
    COUNT(*) FILTER (WHERE NOT success) AS failures,
    COUNT(*) FILTER (WHERE success) AS successes,
    COUNT(*) FILTER (WHERE NOT success)::FLOAT
        / NULLIF(COUNT(*), 0) AS failure_rate
FROM TUMBLE(login_events, event_time, INTERVAL '10 minutes')
GROUP BY user_id, window_start, window_end;

Step 3: Detect impossible travel

Compare consecutive logins per user using a self-join within a sliding window. The Haversine approximation for small distances works within SQL arithmetic.

CREATE MATERIALIZED VIEW impossible_travel AS
WITH consecutive AS (
    SELECT
        user_id,
        event_time AS t2,
        latitude   AS lat2,
        longitude  AS lon2,
        LAG(event_time)  OVER (PARTITION BY user_id ORDER BY event_time) AS t1,
        LAG(latitude)    OVER (PARTITION BY user_id ORDER BY event_time) AS lat1,
        LAG(longitude)   OVER (PARTITION BY user_id ORDER BY event_time) AS lon1
    FROM login_events
    WHERE success = TRUE
)
SELECT
    user_id,
    t1,
    t2,
    SQRT(
        POW(111.32 * (lat2 - lat1), 2) +
        POW(111.32 * COS(RADIANS((lat1 + lat2) / 2)) * (lon2 - lon1), 2)
    ) / NULLIF(EXTRACT(EPOCH FROM (t2 - t1)) / 3600.0, 0) AS speed_kmh
FROM consecutive
WHERE t1 IS NOT NULL;

CREATE MATERIALIZED VIEW impossible_travel_alerts AS
SELECT *
FROM impossible_travel
WHERE speed_kmh > 900;

Step 4: Correlate ATO signals into a risk score

Join the failure rate view and impossible travel alerts to compute a composite risk score per user.

CREATE MATERIALIZED VIEW ato_risk_scores AS
SELECT
    COALESCE(f.user_id, t.user_id) AS user_id,
    CURRENT_TIMESTAMP AS evaluated_at,
    CASE
        WHEN t.user_id IS NOT NULL                THEN 90
        WHEN f.failure_rate > 0.8 AND f.failures > 10 THEN 75
        WHEN f.failure_rate > 0.5                THEN 50
        ELSE 10
    END AS risk_score,
    f.failures,
    f.failure_rate,
    t.speed_kmh
FROM login_failure_rate f
FULL OUTER JOIN impossible_travel_alerts t
    ON f.user_id = t.user_id;

Your application queries ato_risk_scores over the PostgreSQL wire protocol. RisingWave keeps the materialized view continuously updated as new events arrive from Kafka.

Step 5: Sink high-risk events to an alert topic

CREATE SINK ato_alerts_sink
FROM ato_risk_scores
WHERE risk_score >= 75
WITH (
    connector = 'kafka',
    topic = 'ato_alerts',
    properties.bootstrap.server = 'kafka:9092'
) FORMAT PLAIN ENCODE JSON;

Apache Flink can detect ATO, but it imposes a significant operational tax. Writing the credential stuffing detector above in Flink requires Java or Scala DataStream API code, not SQL. Flink stores hot state in RocksDB on local disks, which requires careful checkpoint configuration to survive node failures. When your fraud team wants to adjust the speed threshold from 900 km/h to 800 km/h, Flink requires a job redeployment with state drain.

RisingWave handles all three pain points differently. Detection logic is SQL, so your security team can write and modify rules without an engineer. State lives in S3-compatible object storage, so it survives node failures automatically. Rule changes are ALTER MATERIALIZED VIEW statements that take effect on the next checkpoint without redeployment. And RisingWave runs 22 of 27 Nexmark benchmark streaming queries faster than Flink.

What is the latency of RisingWave ATO detection?

Detection latency is the time from a login event arriving in Kafka to the risk score being available for query. RisingWave processes events incrementally as they arrive and updates materialized views continuously. For the impossible travel view above, latency is typically under one second end-to-end, bounded by Kafka consumer poll intervals and the checkpoint cycle. Tumbling window views emit results at the end of each window period (10 minutes in the example above), which is the design of tumbling semantics.

How does this compare to rule-based fraud engines?

Traditional fraud engines like Kount or Sift evaluate rules one event at a time. They do not natively correlate events across streams or maintain stateful aggregates. You must build a separate pipeline to compute features, store them in a feature store, and join them at scoring time.

RisingWave collapses this architecture. The materialized view IS the feature store. The SQL join across login and transaction streams IS the correlation engine. There is no separate pipeline to maintain.


RisingWave is an open-source PostgreSQL-compatible streaming database. Read the account takeover detection landing page for architecture diagrams and deployment options.

Best-in-Class Event Streaming
for Agents, Apps, and Analytics
GitHubXLinkedInSlackYouTube
Sign up for our to stay updated.