A sign-in that did not succeed, aggregated into an hourly bucket.
One row is NOT one attempt. The unique index
(identifier, ip_network, outcome, bucket_start) is a dedup key, and
PhoenixKit.Users.LoginAttempts.record/1 upserts into it, so a brute-force
run against one account from one network collapses to one row per hour with
a rising attempt_count. Read attempt_count, never count(*).
Fields worth knowing about
identifier— what the person typed, normalized (trimmed, downcased) and truncated to 160 characters. Attacker-controlled text. It is stored verbatim because "someone is hammeringadmin@" is a thing a site owner wants to see, but anything rendering it must escape it, and it is never used to build a query fragment.user_uuid—nilwhen the identifier matched no account. Kept out of the dedup key on purpose: NULL never equals NULL in a Postgres unique index, so keying on it would silently stop deduplicating exactly the rows an attacker generates most of.outcome— why it failed."invalid_credentials"(wrong password, or no such account),"rate_limited"(rejected before credentials were even checked),"inactive"(correct password, deactivated account). The last is the interesting one: someone has the password.bucket_start— the hour, truncated by the caller rather than by a database default, so the value written and the value conflicted on are always the same one.
Summary
Functions
The outcomes outcome may take.
Types
@type t() :: %PhoenixKit.Users.LoginAttempt{ __meta__: term(), attempt_count: term(), browser: term(), bucket_start: term(), first_at: term(), identifier: term(), ip_address: term(), ip_network: term(), last_at: term(), os: term(), outcome: term(), user: term(), user_agent_hash: term(), user_uuid: term(), uuid: term() }
Functions
@spec outcomes() :: [String.t()]
The outcomes outcome may take.