Skip to content

Proposal: per-token failed-attempt limit for OTP verification #2819

Description

@danieldickison

I'd like to contribute this and want to check direction before opening a PR. It picks up the request in supabase discussion #33745.

The gap. POST /auth/v1/verify has no per-token failed-attempt limit. Reading internal/api/verify.go at current master, verifyUserAndToken compares the hash and checks expiry; a mismatch returns otp_expired and writes nothing, so the same code stays valid for its full window. The only throttle is the per-IP rate_limit_verify. For a default 6-digit email code with the default 30 / 5 min per IP and a 10-minute expiry, one IP gets ~60 guesses per code; even odds needs on the order of 8,000 IPs inside the window, which is commodity residential-proxy territory. Longer codes help, but a per-token cap would make the odds independent of how many IPs the attacker has.

Proposed design

  • New column one_time_tokens.failed_attempts integer not null default 0. The existing unique (user_id, token_type) index lets the email path find the row after resolving the user.
  • New config, opt-in for backward compatibility: GOTRUE_MAILER_OTP_MAX_ATTEMPTS and GOTRUE_SMS_OTP_MAX_ATTEMPTS, 0 = disabled (current behavior).
  • On mismatch with the cap enabled: atomic UPDATE … SET failed_attempts = failed_attempts + 1 … RETURNING failed_attempts; when it reaches the max, delete the token (and, on the legacy path, clear the corresponding users.*_token column). The user requests a new code.
  • Return the existing otp_expired error in both the "wrong" and "locked out" cases, so the response doesn't tell an attacker when to restart.
  • Because Verify runs inside db.Transaction and a failed attempt is the error path, the increment would commit in its own transaction before the error is returned.
  • Tests for: N wrong then correct fails; new code resets; disabled by default; concurrent guesses can't exceed the cap.

Questions

  1. Would you accept a PR along these lines?
  2. Given the in-flight one_time_tokens-as-source-of-truth work (feat(otp): switch one_time_tokens table to source of truth #2788, feat(otp): add one_time_tokens query helpers #2797, feat(otp): add link_token_hash column to one_time_tokens table #2800), should this target only the GOTRUE_EXPERIMENTAL_ENABLE_OTT_AS_SOURCE_OF_TRUTH path, or both paths?
  3. Any preference on the default value if you'd rather ship it enabled?

Happy to adjust to whatever fits your roadmap. cc @annabkr since this builds on #2788.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions