ruby 89 lines · 3 tabs

One-Time Password Login: Expiring Token Model, Mailer, and Verification in Rails

Shared by codesnips Sep 2026
3 tabs
class LoginCode < ApplicationRecord
  belongs_to :user

  CODE_LENGTH = 6
  TTL = 10.minutes

  attr_reader :code

  scope :active, -> { where(consumed_at: nil).where("expires_at > ?", Time.current) }
  scope :expired, -> { where("expires_at <= ?", Time.current) }

  def self.generate_for(user)
    transaction do
      user.login_codes.active.update_all(consumed_at: Time.current)

      plaintext = format("%0#{CODE_LENGTH}d", SecureRandom.random_number(10**CODE_LENGTH))
      record = create!(
        user: user,
        digest: BCrypt::Password.create(plaintext),
        expires_at: TTL.from_now
      )
      record.instance_variable_set(:@code, plaintext)
      record
    end
  end

  def verify?(submitted)
    return false if expired? || consumed_at.present?
    BCrypt::Password.new(digest) == submitted.to_s
  end

  def consume!
    update!(consumed_at: Time.current)
  end

  def expired?
    expires_at <= Time.current
  end
end
3 files · ruby Explain with highlit

This snippet shows a passwordless / two-factor login step in Rails built around a single-use, time-limited one-time password (OTP). The core idea is that the plaintext code is never stored: only a hashed digest lives in the database, the code is emailed to the user, and verification re-hashes the submitted value and compares it in constant time before marking the token consumed.

In LoginCode model, generate_for creates a fresh record inside a transaction that first active-scopes out any prior unused codes for that user, preventing several valid codes from floating around at once. A six-digit code is produced with SecureRandom.random_number, hashed with BCrypt::Password.create, and only the digest is persisted alongside an expires_at timestamp. The plaintext is returned via an attr_reader so the caller can mail it, but it is never written to the row. verify? checks expired? and consumed_at before comparing digests, and consume! stamps consumed_at so a code cannot be replayed. The scopes active and expired keep those state checks declarative and reusable.

LoginCodeMailer is a conventional ActionMailer::Base subclass; otp_email pulls the freshly generated plaintext off the model and exposes it to the view template, so the digest-only storage rule is never violated at the delivery layer.

SessionsController wires it together. create looks up the user by email, calls LoginCode.generate_for, and hands the returned object — carrying its transient code — to the mailer via deliver_later, keeping the request fast. It always renders the same neutral response to avoid leaking which addresses exist. verify reloads the newest active code, calls verify?, and on success runs consume! and establishes the session; failure paths cover expired, already-used, and mistyped codes uniformly.

The trade-offs are worth noting: bcrypt makes brute-forcing a leaked digest expensive, but for short numeric codes the real defense is a short expiry plus rate limiting on verify, which a production system would add around find_active_code. Constant-time comparison via BCrypt::Password#== guards against timing attacks. This pattern suits email-based 2FA or magic-code login where a durable, auditable, replay-resistant token is needed.


Related snips

Share this code

Here's the card — post it anywhere.

One-Time Password Login: Expiring Token Model, Mailer, and Verification in Rails — share card
Link copied