PhoenixKit.Users.LoginAttempt (phoenix_kit v2.32.1)

Copy Markdown View Source

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 hammering admin@" 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_uuidnil when 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

t()

@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

outcomes()

@spec outcomes() :: [String.t()]

The outcomes outcome may take.