VerifyIt
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 installRails Setup
1. Generate configuration files
rails generate verify_it:installAvailable 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 --engineIf you chose --storage=database, run the migration:
rails db:migrate2. 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?
end3. Add Verifiable to your model
class User < ApplicationRecord
include VerifyIt::Verifiable
verifies :phone_number # defaults to channel: :sms
verifies :email, channel: :email
endThis 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
endPassing
request: requestenables 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
endPassing 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)
)
}
endRequest/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
endIn 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)
endSecurity
-
secret_key_baseis required — codes are hashed with HMAC-SHA256 before storage - Do not disable rate limiting
- Use
:redisor:databasestorage in production - Keep
code_ttlshort. - Use
on_verify_failureto monitor and alert on repeated failures -
IP-based rate limiting — enable
max_ip_send_attemptsandmax_ip_verification_attemptsto 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
endIP 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 consoleContributing
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.