Project

verify_it

0.0
No release in over 3 years
A storage-agnostic verification system for Ruby applications. VerifyIt provides a flexible framework for implementing SMS and email verifications with built-in rate limiting and multiple storage backends.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 2.3
>= 13.0
~> 3.12
>= 1.50
>= 0.21
>= 1.4
>= 6.1

Runtime

 Project Readme

VerifyIt

Gem Version

A storage-agnostic verification system for Ruby applications.

VerifyIt provides a flexible framework for implementing SMS and email verifications with built-in rate limiting and multiple storage backends.

It allows applications to generate and validate verification codes locally, helping reduce the operational cost associated with per-verification billing models.

Features

  • Storage Agnostic: Redis, Memory, or Database storage
  • Delivery Agnostic: Bring your own SMS/email provider (Twilio, SendGrid, etc.)
  • Built-in Rate Limiting: Configurable limits for sends and verifications
  • Thread-Safe: Designed for concurrent access
  • Test Mode: Expose codes for testing without delivery
  • Rails Optional: Works standalone or with Rails
  • Little Runtime Dependencies: Only ActiveSupport required

Installation

Add to your Gemfile:

gem 'verify_it'
bundle install

Rails Setup

1. Generate configuration files

rails generate verify_it:install

Available options:

  • --storage=memory|redis|database (default: memory)
  • --engine — mount the VerifyIt engine and generate resolver config

Examples:

rails generate verify_it:install --storage=redis
rails generate verify_it:install --storage=database --engine

If you chose --storage=database, run the migration:

rails db:migrate

2. Configure the initializer

Open config/initializers/verify_it.rb and fill in your delivery lambdas.

Redis + Twilio example:

VerifyIt.configure do |config|
  config.secret_key_base = Rails.application.secret_key_base

  config.storage    = :redis
  config.redis_client = Redis.new(url: ENV["REDIS_URL"])

  config.sms_sender = ->(to:, code:, context:) {
    Twilio::REST::Client.new.messages.create(
      from: ENV["TWILIO_NUMBER"],
      to:   to,
      body: "Your verification code is: #{code}"
    )
  }

  config.email_sender = ->(to:, code:, context:) {
    EmailClient.new(to:).send("Your verification code is: #{code}")
  }

  config.test_mode       = Rails.env.test?
  config.bypass_delivery = Rails.env.test?
end

3. Add Verifiable to your model

class User < ApplicationRecord
  include VerifyIt::Verifiable

  verifies :phone_number           # defaults to channel: :sms
  verifies :email, channel: :email
end

This generates the following methods on User:

Method Description
send_sms_code Send a code to phone_number via SMS
send_email_code Send a code to email via email
verify_sms_code(code) Verify an SMS code
verify_email_code(code) Verify an email code
cleanup_sms_verification Remove stored verification data
cleanup_email_verification Remove stored verification data

4. Use it in your controllers

Sending a code:

class VerificationsController < ApplicationController
  def create
    result = current_user.send_sms_code(request: request)

    if result.success?
      render json: { expires_at: result.expires_at }
    elsif result.rate_limited?
      render json: { error: "Too many requests" }, status: :too_many_requests
    else
      render json: { error: result.message }, status: :unprocessable_entity
    end
  end
end

Passing request: request enables IP-based rate limiting. Without it, only per-record rate limits apply.

Verifying a code:

class VerificationsController < ApplicationController
  def update
    result = current_user.verify_sms_code(params[:code], request: request)

    if result.verified?
      session[:phone_verified] = true
      render json: { verified: true }
    elsif result.locked?
      render json: { error: "Too many failed attempts. Try again later." }, status: :forbidden
    else
      remaining = VerifyIt.configuration.max_verification_attempts - result.attempts
      render json: { error: "Invalid code. #{remaining} attempts remaining." }
    end
  end
end

Passing context

Both send_* methods accept an optional context: hash that is forwarded to your sender lambda. Use it for custom message templates or request metadata:

current_user.send_sms_code(request: request, context: {
  message_template: :code_verification_v1,
  locale: :es
})

current_user.send_email_code(context: {
  ip:         request.ip,
  user_agent: request.user_agent
})

Rails Engine (HTTP Endpoints)

VerifyIt ships a mountable Rails Engine that exposes two JSON endpoints. Use the engine when you want drop-in endpoints without writing controller code. This is optional - skip this section if you prefer to handle verification in your own controllers using the Verifiable concern or the plain Ruby API.

If you ran the installer with --engine, the mount and resolver config are already generated for you. The details below are for reference or manual setup.

Mount the engine

# config/routes.rb
mount VerifyIt::Engine, at: "/verify"

This adds two routes:

Method Path Action
POST /verify/request Send a verification code
POST /verify/confirm Confirm a verification code

Configure the resolvers

Two additional config options are required when using the engine endpoints:

# config/initializers/verify_it.rb
VerifyIt.configure do |config|
  config.secret_key_base = Rails.application.secret_key_base
  # ... your storage config (see Configuration Reference) ...

  # Resolve the authenticated record from the request (e.g. from a session or JWT).
  config.current_record_resolver = ->(request) {
    User.find_by(id: request.session[:user_id])
  }

  # Derive the delivery identifier from the record and the requested channel.
  config.identifier_resolver = ->(record, channel) {
    channel == :sms ? record.phone_number : record.email
  }

  config.sms_sender = ->(to:, code:, context:) {
    Twilio::REST::Client.new.messages.create(
      from: ENV["TWILIO_NUMBER"],
      to:   to,
      body: I18n.t("verify_it.sms.default_message", code: code)
    )
  }
end

Request/response examples

Send a code:

POST /verify/request
Content-Type: application/json

{ "channel": "sms" }

# 201 Created
{ "message": "Verification code sent." }

# 429 Too Many Requests
{ "error": "Too many attempts. Please try again later." }

Confirm a code:

POST /verify/confirm
Content-Type: application/json

{ "channel": "sms", "code": "123456" }

# 200 OK
{ "message": "Successfully verified." }

# 422 Unprocessable Entity
{ "error": "The code you entered is invalid." }

I18n overrides

All messages are stored in config/locales/en.yml and can be overridden in your application's locale files:

en:
  verify_it:
    sms:
      default_message: "%{code} is your one-time passcode."
    responses:
      verified: "Identity confirmed."

Plain Ruby

require "verify_it"

VerifyIt.configure do |config|
  config.secret_key_base = ENV.fetch("VERIFY_IT_SECRET")
  config.storage         = :memory  # :memory, :redis, or :database

  config.sms_sender = ->(to:, code:, context:) {
    MySmsSender.deliver(to: to, body: "Your code: #{code}")
  }
end

# Send a code
result = VerifyIt.send_code(to: "+15551234567", channel: :sms, record: user)

# Verify a code
result = VerifyIt.verify_code(to: "+15551234567", code: "123456", record: user)

# Clean up
VerifyIt.cleanup(to: "+15551234567", record: user)

Configuration Reference

Option Default Accepted values
secret_key_base nil unless using Rails Any string — required
storage :memory :memory, :redis, :database
redis_client nil A Redis instance (required when storage: :redis)
code_length 6 Integer
code_ttl 300 Seconds (integer)
code_format :numeric :numeric, :alphanumeric, :alpha
max_send_attempts 3 Integer
max_verification_attempts 5 Integer
max_identifier_changes 5 Integer
max_ip_send_attempts nil Integer or nil (disabled) — max sends per IP per window
max_ip_verification_attempts nil Integer or nil (disabled) — max verifications per IP per window
rate_limit_window 3600 Seconds (integer)
delivery_channel :sms :sms, :email
sms_sender nil Lambda (to:, code:, context:) { }
email_sender nil Lambda (to:, code:, context:) { }
on_send nil Lambda (record:, identifier:, channel:) { }
on_verify_success nil Lambda (record:, identifier:, request: nil) { }
on_verify_failure nil Lambda (record:, identifier:, attempts:) { }
current_record_resolver nil Lambda (request) { } — required when mounting the engine
identifier_resolver nil Lambda (record, channel) { } — required when mounting the engine
namespace nil Lambda (record) { } returning a string or nil
test_mode false Boolean — exposes result.code
bypass_delivery false Boolean — skips actual delivery

Callbacks

config.on_send = ->(record:, identifier:, channel:) {
  Analytics.track("verification_sent", user_id: record.id, channel: channel)
}

config.on_verify_success = ->(record:, identifier:, request: nil) {
  Analytics.track("verification_success", user_id: record.id)
}

config.on_verify_failure = ->(record:, identifier:, attempts:) {
  Analytics.track("verification_failure", user_id: record.id, attempts: attempts)
}

Namespacing

Isolate verification data per tenant:

config.namespace = ->(record) { "org:#{record.organization_id}" }

Result Object

Every operation returns a VerifyIt::Result:

Method Type Description
success? Boolean Operation completed without error
verified? Boolean Code matched successfully
rate_limited? Boolean Rate limit was exceeded
locked? Boolean Max verification attempts reached
error Symbol :invalid_code, :rate_limited, :code_not_found, :locked, :delivery_failed
message String Human-readable description
code String The verification code (only when test_mode: true)
expires_at Time When the code expires
attempts Integer Number of verification attempts so far

Testing

Configure VerifyIt in your spec helper to avoid real delivery and expose codes:

# spec/support/verify_it.rb
RSpec.configure do |config|
  config.before(:each) do
    VerifyIt.configure do |c|
      c.storage         = :memory
      c.test_mode       = true
      c.bypass_delivery = true
      c.secret_key_base = "test_secret"
    end
  end
end

In a request spec (with the engine mounted):

it "verifies phone number" do
  post "/verify/request", params: { channel: "sms" }, headers: auth_headers
  code = JSON.parse(response.body)["code"]  # available in test_mode

  post "/verify/confirm", params: { channel: "sms", code: code }, headers: auth_headers
  expect(response).to have_http_status(:ok)
end

Security

  • secret_key_base is required — codes are hashed with HMAC-SHA256 before storage
  • Do not disable rate limiting
  • Use :redis or :database storage in production
  • Keep code_ttl short.
  • Use on_verify_failure to monitor and alert on repeated failures
  • IP-based rate limiting — enable max_ip_send_attempts and max_ip_verification_attempts to protect against SMS pumping and distributed brute force attacks:
VerifyIt.configure do |config|
  config.max_ip_send_attempts = 10          # max 10 sends per IP per window
  config.max_ip_verification_attempts = 20  # max 20 verifications per IP per window
end

IP rate limiting requires passing request: to send_code/verify_code. The engine controller does this automatically. For custom controllers, pass request: explicitly.


Development

bin/setup           # Install dependencies
bundle exec rspec   # Run tests
bundle exec rubocop # Lint
bin/console         # Interactive console

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/JeremasPosta/verify_it. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the VerifyIt project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.