A comprehensive Rails plugin for One-Time Password (OTP) generation, delivery, and verification with multiple SMS provider support.
- 🔐 Secure OTP Generation: Numeric and alphanumeric OTP support
- 📱 SMS Provider Support: Built-in Cequens provider with HTTParty
- ⚡ Interactor Pattern: Clean business logic separation using interactors
- 🛡️ Rate Limiting: Configurable hourly and daily limits
- 📝 Custom Messages: Simple message override with {code} placeholder
- 🧪 Test Mode: Mock SMS sending for development and testing
- 📊 Dual Storage Options: Database or Redis storage backends
- 🚀 Redis Support: High-performance Redis storage with auto-expiration
- ⚙️ Flexible Configuration: DSL-based configuration system
- 🔄 Retry Logic: Automatic retries for failed SMS deliveries
- 🎯 Purpose-Based OTPs: Independent OTP types for different use cases
- ⚡ Performance Optimized: Singleton Redis connections for better throughput
- 🎛️ Custom OTP Support: Send your own OTP codes with validation
Add this line to your application's Gemfile:
gem 'otp_engine', path: 'path/to/otp_engine' # or from git/rubygems$ bundle install
$ rails generate otp_engine:install
$ rails db:migrate$ bundle install
$ rails generate otp_engine:install --redis-only
# No migration needed - Redis handles all storage- Standard: Creates database migration and supports both storage backends
- Redis-Only: Skips database setup, uses Redis for all OTP storage
- Hybrid: Start with database, switch to Redis later by updating configuration
# config/initializers/otp_engine.rb
OtpEngine.configure do |config|
config.default_provider = :cequens
# Storage Backend
config.store_in_database = true
# OTP Settings
config.otp_length = 6
config.otp_type = :numeric
config.expires_in = 600 # 10 minutes
config.test_mode = false # Set to true for development
# Provider Configuration
config.provider :cequens do |cequens|
cequens.set :api_token, ENV['CEQUENS_API_TOKEN']
cequens.set :sender_name, ENV['CEQUENS_SENDER_NAME']
cequens.set :api_base_url, 'https://apis.cequens.com'
end
end# config/initializers/otp_engine.rb
OtpEngine.configure do |config|
config.default_provider = :cequens
# Storage Backend
config.store_in_database = false
# Redis Configuration - OTP Engine creates new connection
config.redis_config = {
url: ENV['REDIS_URL'] || 'redis://localhost:6379/0',
timeout: 5
}
# Example Redis URLs:
# redis://localhost:6379/0 # Local Redis
# redis://username:password@redis.com:6379/0 # With authentication
# rediss://redis.herokuapp.com:6379 # SSL Redis
# OTP Settings
config.otp_length = 6
config.otp_type = :numeric
config.expires_in = 600 # 10 minutes
# Provider Configuration
config.provider :cequens do |cequens|
cequens.set :api_token, ENV['CEQUENS_API_TOKEN']
cequens.set :sender_name, ENV['CEQUENS_SENDER_NAME']
cequens.set :api_base_url, 'https://apis.cequens.com'
end
end# config/initializers/redis.rb
$redis = Redis.new(url: ENV['REDIS_URL'])
# config/application.rb (or config/initializers/otp_engine.rb)
Rails.application.config.after_initialize do
OtpEngine.configure do |config|
config.default_provider = :cequens
# Storage Backend
config.store_in_database = false
# Use your existing Redis client
config.redis_client = $redis
# or config.redis_client = Rails.application.config.redis
# or config.redis_client = Redis.current
# OTP Settings
config.otp_length = 6
config.otp_type = :numeric
config.expires_in = 600 # 10 minutes
# Provider Configuration
config.provider :cequens do |cequens|
cequens.set :api_token, ENV['CEQUENS_API_TOKEN']
cequens.set :sender_name, ENV['CEQUENS_SENDER_NAME']
cequens.set :api_base_url, 'https://apis.cequens.com'
end
end
end# config/initializers/otp_engine.rb
require 'connection_pool'
OtpEngine.configure do |config|
config.default_provider = :cequens
config.store_in_database = false
# Use connection pool for better concurrency
config.redis_client = ConnectionPool.new(size: 10, timeout: 5) do
Redis.new(url: ENV['REDIS_URL'] || 'redis://localhost:6379/0')
end
# ... other configuration
endProblem: Rails initializers load alphabetically, so otp_engine.rb might load before redis.rb.
Simple Solution: Use Rails.application.config.after_initialize (as shown in Option 2 above)
This ensures OTP Engine configuration runs after all initializers have loaded, so your Redis client will be available.
Alternative: Rename your initializers with numbers:
# config/initializers/01_redis.rb
$redis = Redis.new(url: ENV['REDIS_URL'])
# config/initializers/02_otp_engine.rb
OtpEngine.configure do |config|
config.redis_client = $redis
end# Send OTP with required purpose
result = OtpEngine.send_otp('+201234567890', { purpose: :verification })
if result.success?
puts "OTP sent! Message ID: #{result.message_id}"
puts "OTP Code: #{result.otp_code}" # For testing only!
else
puts "Error: #{result.error}"
end
# Send OTP with custom message and purpose
result = OtpEngine.send_otp('+201234567890', {
message: "Your login code is: {code}",
purpose: :login
})
# Verify OTP (purpose must match what was used when sending)
result = OtpEngine.verify_otp('+201234567890', '123456', { purpose: :login })
if result.success?
puts "OTP verified successfully!"
else
puts "Verification failed: #{result.error}"
endYou can send your own OTP codes instead of letting the engine generate them. Custom OTPs are validated against your configured otp_length and otp_type settings.
# Send a custom OTP code
result = OtpEngine.send_otp('+201234567890', {
custom_otp: 'ABC123',
purpose: :verification
})
# Custom OTP validation respects your configuration:
# - Length must match config.otp_length (default: 6)
# - Characters must match config.otp_type (:numeric or :alphanumeric)
# Example with numeric-only configuration
OtpEngine.config.otp_type = :numeric
result = OtpEngine.send_otp('+201234567890', {
custom_otp: '123456', # ✅ Valid: 6 digits
purpose: :verification
})
# This will fail validation
result = OtpEngine.send_otp('+201234567890', {
custom_otp: 'ABC123', # ❌ Invalid: contains letters when numeric only
purpose: :verification
})
# => Error: "Custom OTP code can only contain numbers"
# Example with alphanumeric configuration
OtpEngine.config.otp_type = :alphanumeric
result = OtpEngine.send_otp('+201234567890', {
custom_otp: 'ABC123', # ✅ Valid: 6 alphanumeric characters
purpose: :verification
})Custom OTP Configuration:
- Enable/disable:
config.allow_custom_otp = true(default: true) - Length validation: Uses
config.otp_length(default: 6) - Character validation: Uses
config.otp_type(:numeric or :alphanumeric) - Error messages: Clear validation feedback for invalid custom OTPs
class AuthController < ApplicationController
def send_otp
result = OtpEngine.send_otp(
params[:phone_number],
message: 'Your login code: {code}',
purpose: :login
)
if result.success?
render json: {
success: true,
message: 'OTP sent successfully'
}
else
render json: {
success: false,
error: result.error
}, status: :unprocessable_entity
end
end
def verify_otp
result = OtpEngine.verify_otp(
params[:phone_number],
params[:otp_code],
purpose: :login
)
if result.success?
# Handle successful verification
user = User.find_by(phone_number: params[:phone_number])
sign_in(user) if user
render json: {
success: true,
message: 'Login successful'
}
else
render json: {
success: false,
error: result.error
}, status: :unprocessable_entity
end
end
endOTP Engine supports different purposes for OTPs, allowing you to separate login codes from registration codes, password resets, etc. Each purpose has independent rate limiting and validation.
The following purpose values are supported:
:verification(default) - General verification:login- User login:registration- User registration:password_reset- Password reset:phone_verification- Phone number verification:two_factor- Two-factor authentication
# Registration OTP
result = OtpEngine.send_otp('+201234567890', {
purpose: :registration,
message: "Welcome! Your registration code is: {code}"
})
# Login OTP (separate from registration)
result = OtpEngine.send_otp('+201234567890', {
purpose: :login,
message: "Your login code is: {code}"
})
# Password Reset OTP
result = OtpEngine.send_otp('+201234567890', {
purpose: :password_reset,
message: "Your password reset code is: {code}"
})
# Verify with matching purpose
result = OtpEngine.verify_otp('+201234567890', '123456', { purpose: :login })- Purpose must match: When verifying, you must use the same purpose that was used when sending
- Independent rate limits: Each purpose has separate hourly/daily rate limits
- Validation: Invalid purpose values will return an error with valid options
- Required parameter: Purpose must be specified for both sending and verifying OTPs
OTP Engine supports two storage backends that can be chosen based on your application's needs:
- When to use: Traditional Rails applications with existing database infrastructure
- Benefits: Audit trail, complex queries, familiar ActiveRecord interface
- Setup: Requires database migration, uses
OtpRecordmodel - Configuration:
config.store_in_database = true
- When to use: High-throughput applications, microservices, stateless deployments
- Benefits: Better performance, automatic expiration, no database dependencies
- Setup: Requires Redis server, no migrations needed
- Configuration:
config.store_in_database = false+redis_configorredis_client
- New Connection (
redis_config): OTP Engine creates its own Redis connection - Existing Client (
redis_client): Reuse your application's Redis connection - Connection Pool: Use connection pooling for high-concurrency applications
Recommendation: Use redis_client to inject your existing Redis connection for better resource management.
Redis Advantages:
- ⚡ Faster Operations: In-memory storage for sub-millisecond access
- 🚀 Auto-Expiration: Built-in TTL eliminates cleanup jobs
- 📈 High Throughput: Optimized for concurrent OTP operations
- 🔧 Connection Pooling: Singleton pattern prevents connection churn
Database Advantages:
- 📊 Rich Queries: Complex analytics and reporting capabilities
- 🔍 Audit Trail: Complete history of OTP operations
- 🛠️ Familiar Tools: Standard Rails/ActiveRecord patterns
Sends an OTP to the specified phone number.
Parameters:
phone_number(String): Target phone numberoptions(Hash): Options hash:purpose- Required. Purpose of OTP (:verification,:login,:registration,:password_reset,:phone_verification,:two_factor):message- Custom message template with {code} placeholder:provider- Specific provider to use:otp_length- Override default OTP length:otp_type- Override default OTP type:expires_in- Override default expiration:custom_otp- Send your own OTP code (validated against config settings)
Returns: Interactor result with:
success?- Boolean indicating successotp_code- Generated OTP codemessage_id- Provider message IDotp_record- Database record (if stored)error- Error message on failureerror_code- Error code symbol
Verifies an OTP code for the given phone number.
Parameters:
phone_number(String): Phone number to verifycode(String): OTP code to verifyoptions(Hash): Options hash:purpose- Required. Must match the purpose used when sending the OTP
Returns: Interactor result with:
success?- Boolean indicating successverified- Boolean confirmation of verificationotp_record- Database record (if stored)error- Error message on failureerror_code- Error code symbol
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
The gem is available as open source under the terms of the MIT License.