Building a Custom SSO: Why We Chose OAuth2 Over Devise

by | Mar 31, 2025 | Coding, Rails Renaissance | 0 comments

Reading Time: 14 minutes | Series: Rails Renaissance #2


When we built Heis Soma in 2021, we made a decision that seemed crazy: build our own OAuth2 Single Sign-On system instead of using Devise. Three years and three Rails versions later, that “crazy” decision looks like prophecy.

“Just use Devise” is the standard Rails authentication advice. It’s battle-tested, well-documented, and used by thousands of applications. For 95% of Rails apps, it’s the right choice. But sometimes—just sometimes—the 5% edge case is exactly where you are.

This is the story of why we built Heis Soma (Greek: Εἷς Σῶμα, “One Body”), our custom OAuth2 authentication service that now powers Prayer Nook and positions us to serve the broader Christian ministry ecosystem. It’s a story about architectural decisions, technical tradeoffs, and the surprising ways that following standards can set you free.

Whether you’re considering custom authentication, evaluating Devise alternatives, or just curious about what it takes to build production-grade OAuth2, grab your favorite beverage and let’s dive deep.

Ember’s Opening: “Sometimes the path less traveled leads to undiscovered territory—and sometimes it just leads to more work. Here’s how we made sure it was the former.” 🐧🔥

The Context: Why Authentication Mattered So Much

Before we get into the technical details, you need to understand what we were building and why authentication was so critical.

The Christian Chain Ecosystem

The Christian Chain isn’t just one application—it’s a growing ecosystem of ministry tools:

Prayer Nook (launched 2022) – A prayer request platform where community members share needs and pray for each other. Think social network meets spiritual support group.

Unity Accord (in development) – A platform for churches to affirm core Christian beliefs while respecting denominational differences. Digital signatures, theological discussion, community building.

Future Applications – We had plans for:

  • Small group management tools
  • Bible study platforms
  • Event coordination systems
  • Giving management
  • Volunteer scheduling

Each application needed:

  • User authentication – Who are you?
  • Permission management – What can you do?
  • Profile consistency – One identity across all tools
  • Privacy controls – Ministry-specific visibility settings
  • Shared sessions – Login once, access everything

The Theological Dimension

The name “Heis Soma” comes from 1 Corinthians 12—Paul’s teaching about the church being “one body” despite many different members with different gifts. This wasn’t just a cute theological reference; it shaped our entire architecture:

One Body, Many Members:

  • Centralized identity (one body)
  • Distributed applications (many members)
  • Unified purpose (ministry)
  • Respect for differences (different gifts)

This theological framework led us toward SSO as a technical solution. Users shouldn’t need separate accounts for prayer requests, church events, and small groups any more than your hand needs a separate brain from your foot.

The Devise Decision: Why Not?

Let’s be clear: Devise is excellent software. It’s mature, well-maintained, secure, and solves authentication for thousands of Rails apps. So why didn’t we use it?

Limitation #1: Single Application Focus

Devise is designed for authentication within an application, not across applications. While you can share a Devise setup across apps with some gymnastics, it’s not the tool’s primary use case.

# What Devise does great:
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
end

# What we needed:
# Users authenticate ONCE in Heis Soma
# Then access Prayer Nook, Unity Accord, Events, etc.
# Each app trusts Heis Soma for authentication

We could have run Devise in each app with a shared database, but:

  • Session management gets complex
  • Cookie domain issues
  • No standard protocol for trust
  • Harder to add third-party apps later

Limitation #2: OAuth2 Provider Not Built-In

Devise handles OAuth2 clients well (login with Google, Facebook, etc.) through OmniAuth, but it’s not designed to be an OAuth2 provider. You can add this with the Doorkeeper gem, but then you’re combining two complex authentication systems.

We evaluated the Devise + Doorkeeper combination:

Pros:

  • Both are well-maintained
  • Good documentation
  • Community support

Cons:

  • Two separate authentication layers to maintain
  • Conceptual overlap and potential conflicts
  • Heavier dependency footprint
  • More “magic” to understand and debug

Limitation #3: Lock-in to Rails Ecosystem

This was the subtle one. Devise is deeply Rails-integrated (which makes it powerful within Rails), but what if we wanted to:

  • Build mobile apps with native authentication?
  • Allow partner ministries to integrate?
  • Add authentication to non-Rails services?
  • Provide SSO to church’s existing tools?

OAuth2 is a standard protocol that works across languages, frameworks, and platforms. A properly implemented OAuth2 provider can serve:

  • Rails apps (with gems)
  • React Native apps (with libraries)
  • iOS/Android native apps
  • Third-party integrations
  • Even non-web services

Limitation #4: Future-Proofing Concerns

In 2021, we were starting a nonprofit ministry with limited resources. We couldn’t afford to paint ourselves into a corner architecturally. Questions haunted me:

  • What if Rails authentication patterns shift radically?
  • What if we need to migrate away from Rails someday?
  • What if partner churches want to integrate their tools?
  • What if we need enterprise-grade features later?

OAuth2 is an IETF standard (RFC 6749). It’s not going anywhere. Building on a standard meant our authentication would outlive any particular framework’s trends.

The Decision: Custom OAuth2 with Standards Compliance

So we decided to build our own. Not because we thought we were smarter than the Devise team (we’re definitely not), but because our needs aligned better with OAuth2 principles than monolithic authentication.

Core Principles

1. Standards Over Cleverness

We would implement OAuth2 exactly by the spec, not invent our own variant. This meant:

  • Following RFC 6749 to the letter
  • Implementing standard grant flows
  • Using JWT for tokens (RFC 7519)
  • Supporting PKCE for mobile (RFC 7636)
  • Following OAuth2 security best practices

2. Rails-Native Implementation

While OAuth2 is framework-agnostic, our implementation would be deeply Rails-integrated:

  • ActiveRecord models
  • Rails routing conventions
  • ActionController inheritance
  • Rails caching
  • ActiveJob for async tasks

Best of both worlds: standard protocol, idiomatic Rails code.

3. API-First Design

Everything would be API-accessible from day one:

  • RESTful endpoints
  • JSON responses
  • Versioned API (v1, v2, etc.)
  • Comprehensive documentation
  • Client libraries for common cases

This would make integrations easier and force us to think about the contract between Heis Soma and client applications.

4. Security-First Mentality

Authentication is security-critical. We would:

  • Hash all tokens before storage
  • Implement rate limiting
  • Audit log everything
  • Regular security reviews
  • Stay current on CVEs
  • Encrypt sensitive data at rest

The Architecture: How Heis Soma Works

Let me walk you through the actual implementation. This isn’t theoretical—this is production code serving 1,000+ users across multiple applications.

The OAuth2 Flow (Simplified)

Here’s what happens when you click “Login” in Prayer Nook:

1. User clicks "Login with Christian Chain"
   Prayer Nook → https://heissoma.org/oauth/authorize?
                 client_id=prayer_nook&
                 redirect_uri=https://prayernook.org/callback&
                 response_type=code&
                 scope=basic+email+prayer_wall

2. Heis Soma shows login screen
   User enters email/password
   
3. Heis Soma validates credentials
   User sees: "Prayer Nook wants to access..."
   User clicks "Authorize"
   
4. Heis Soma redirects back with authorization code
   → https://prayernook.org/callback?code=ABC123XYZ
   
5. Prayer Nook exchanges code for access token
   POST to https://heissoma.org/oauth/token
   {
     "grant_type": "authorization_code",
     "code": "ABC123XYZ",
     "client_id": "prayer_nook",
     "client_secret": "[secret]",
     "redirect_uri": "https://prayernook.org/callback"
   }
   
6. Heis Soma returns tokens
   {
     "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
     "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
     "token_type": "Bearer",
     "expires_in": 3600,
     "scope": "basic email prayer_wall"
   }
   
7. Prayer Nook uses access token for API requests
   GET https://heissoma.org/api/v1/user
   Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
   
8. Heis Soma validates token, returns user info
   {
     "id": 123,
     "email": "user@example.com",
     "name": "John Doe",
     "username": "jdoe",
     "permissions": ["pray", "post_requests", "comment"]
   }

This is the Authorization Code Grant flow—the most secure OAuth2 flow for web applications.

The Database Schema

Clean, focused tables that do one thing well:

# app/models/user.rb
class User < ApplicationRecord
  has_many :oauth_access_tokens, dependent: :destroy
  has_many :oauth_access_grants, dependent: :destroy
  has_many :authorizations, dependent: :destroy
  has_many :permissions, dependent: :destroy
  
  # BCrypt password hashing
  has_secure_password
  
  validates :email, presence: true, uniqueness: true,
            format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :username, presence: true, uniqueness: true,
            format: { with: /\A[a-zA-Z0-9_]+\z/ }
  
  # Soft delete for GDPR compliance
  acts_as_paranoid
end

# app/models/oauth_application.rb
class OauthApplication < ApplicationRecord
  has_many :oauth_access_tokens, dependent: :destroy
  has_many :oauth_access_grants, dependent: :destroy
  
  validates :name, presence: true
  validates :uid, presence: true, uniqueness: true
  validates :secret, presence: true
  validates :redirect_uri, presence: true
  
  # Trust levels for different app types
  enum trust_level: {
    first_party: 0,   # Our own apps
    partner: 1,       # Verified ministries
    third_party: 2    # Public applications
  }
  
  # Generate secure UID and secret on creation
  before_validation :generate_credentials, on: :create
  
  private
  
  def generate_credentials
    self.uid ||= SecureRandom.hex(16)
    self.secret ||= SecureRandom.hex(32)
  end
end

# app/models/oauth_access_token.rb
class OauthAccessToken < ApplicationRecord
  belongs_to :user
  belongs_to :oauth_application
  
  # Store hashed token, not plaintext
  before_create :generate_token
  before_create :set_expiration
  
  # Scopes as array
  serialize :scopes, Array
  
  scope :active, -> { where('expires_at > ?', Time.current).where(revoked_at: nil) }
  
  def expired?
    expires_at < Time.current
  end
  
  def revoked?
    revoked_at.present?
  end
  
  def valid_token?
    !expired? && !revoked?
  end
  
  private
  
  def generate_token
    # Generate JWT
    payload = {
      user_id: user.id,
      app_id: oauth_application.id,
      scopes: scopes,
      exp: 1.hour.from_now.to_i,
      iat: Time.current.to_i
    }
    
    self.token = JWT.encode(payload, Rails.application.credentials.jwt_secret, 'RS256')
    self.token_digest = Digest::SHA256.hexdigest(token)
  end
  
  def set_expiration
    self.expires_at = 1.hour.from_now
  end
end

The Controllers (Where OAuth2 Happens)

Authorization Endpoint:

# app/controllers/oauth/authorizations_controller.rb
module Oauth
  class AuthorizationsController < ApplicationController
    before_action :authenticate_user!
    before_action :validate_client_application
    before_action :validate_redirect_uri
    
    # GET /oauth/authorize
    def new
      # Check if user has already authorized this app
      existing_grant = current_user.oauth_access_grants
        .find_by(oauth_application: @application)
        
      if existing_grant && @application.trusted?
        # Skip authorization screen for trusted apps
        create_and_redirect
      else
        # Show authorization screen
        @scopes = parse_requested_scopes
        render :new
      end
    end
    
    # POST /oauth/authorize
    def create
      if params[:authorize] == 'true'
        create_and_redirect
      else
        redirect_to oauth_error_url('access_denied',
          'User denied authorization')
      end
    end
    
    private
    
    def create_and_redirect
      # Create authorization code
      auth_code = create_authorization_code(
        user: current_user,
        application: @application,
        redirect_uri: params[:redirect_uri],
        scopes: parse_requested_scopes,
        code_challenge: params[:code_challenge]  # PKCE support
      )
      
      redirect_to build_callback_url(auth_code.code)
    end
    
    def validate_client_application
      client_id = params[:client_id]
      @application = OauthApplication.find_by(uid: client_id)
      
      return if @application
      
      redirect_to oauth_error_url('invalid_client',
        'Unknown client application')
    end
    
    def validate_redirect_uri
      requested_uri = params[:redirect_uri]
      allowed_uris = @application.redirect_uri.split("\n")
      
      return if allowed_uris.include?(requested_uri)
      
      redirect_to oauth_error_url('invalid_request',
        'Invalid redirect URI')
    end
    
    def build_callback_url(code)
      uri = URI.parse(params[:redirect_uri])
      uri.query = URI.encode_www_form(
        code: code,
        state: params[:state]  # Anti-CSRF
      )
      uri.to_s
    end
  end
end

Token Endpoint:

# app/controllers/oauth/tokens_controller.rb
module Oauth
  class TokensController < ApplicationController
    skip_before_action :verify_authenticity_token  # API endpoint
    before_action :validate_client_credentials
    
    # POST /oauth/token
    def create
      case params[:grant_type]
      when 'authorization_code'
        handle_authorization_code_grant
      when 'refresh_token'
        handle_refresh_token_grant
      else
        render json: { error: 'unsupported_grant_type' }, status: :bad_request
      end
    end
    
    # POST /oauth/revoke
    def revoke
      token = OauthAccessToken.find_by(token_digest: token_digest(params[:token]))
      
      if token
        token.update(revoked_at: Time.current)
        head :ok
      else
        head :not_found
      end
    end
    
    private
    
    def handle_authorization_code_grant
      code = OauthAuthorizationCode.find_by(code: params[:code])
      
      unless code&.valid_code?
        return render json: { error: 'invalid_grant' }, status: :bad_request
      end
      
      # Validate PKCE if present
      if code.code_challenge.present?
        unless validate_pkce(code)
          return render json: { error: 'invalid_grant' }, status: :bad_request
        end
      end
      
      # Exchange code for tokens
      access_token = create_access_token(
        user: code.user,
        application: code.oauth_application,
        scopes: code.scopes
      )
      
      refresh_token = create_refresh_token(
        user: code.user,
        application: code.oauth_application
      )
      
      # Mark code as used (one-time use only)
      code.destroy
      
      render json: {
        access_token: access_token.token,
        refresh_token: refresh_token.token,
        token_type: 'Bearer',
        expires_in: access_token.expires_in,
        scope: access_token.scopes.join(' ')
      }
    end
    
    def handle_refresh_token_grant
      refresh_token = OauthRefreshToken.find_by(
        token_digest: token_digest(params[:refresh_token])
      )
      
      unless refresh_token&.valid_token?
        return render json: { error: 'invalid_grant' }, status: :bad_request
      end
      
      # Create new access token
      access_token = create_access_token(
        user: refresh_token.user,
        application: refresh_token.oauth_application,
        scopes: refresh_token.scopes
      )
      
      # Rotate refresh token (security best practice)
      new_refresh_token = create_refresh_token(
        user: refresh_token.user,
        application: refresh_token.oauth_application
      )
      
      refresh_token.destroy  # Invalidate old one
      
      render json: {
        access_token: access_token.token,
        refresh_token: new_refresh_token.token,
        token_type: 'Bearer',
        expires_in: access_token.expires_in,
        scope: access_token.scopes.join(' ')
      }
    end
    
    def validate_pkce(code)
      verifier = params[:code_verifier]
      challenge = code.code_challenge
      method = code.code_challenge_method || 'plain'
      
      case method
      when 'plain'
        verifier == challenge
      when 'S256'
        Base64.urlsafe_encode64(
          Digest::SHA256.digest(verifier),
          padding: false
        ) == challenge
      else
        false
      end
    end
    
    def validate_client_credentials
      client_id = params[:client_id]
      client_secret = params[:client_secret]
      
      @application = OauthApplication.find_by(uid: client_id)
      
      unless @application&.secret == client_secret
        render json: { error: 'invalid_client' }, status: :unauthorized
      end
    end
    
    def token_digest(token)
      Digest::SHA256.hexdigest(token)
    end
  end
end

Security Measures (The Non-Negotiables)

1. Token Hashing

We NEVER store plaintext tokens:

# When creating a token
token = SecureRandom.hex(32)
token_digest = Digest::SHA256.hexdigest(token)

# Store only the digest
OauthAccessToken.create(
  token_digest: token_digest,
  # other attributes...
)

# Return the plaintext token ONCE to the client
{ access_token: token }

# When validating
submitted_digest = Digest::SHA256.hexdigest(submitted_token)
OauthAccessToken.find_by(token_digest: submitted_digest)

2. Rate Limiting

Protect against brute force and DoS:

# config/initializers/rack_attack.rb
Rack::Attack.throttle('login attempts', limit: 5, period: 15.minutes) do |req|
  if req.path == '/oauth/token' && req.post?
    req.ip
  end
end

Rack::Attack.throttle('token requests', limit: 10, period: 1.minute) do |req|
  if req.path == '/oauth/token'
    req.params['client_id']
  end
end

3. Audit Logging

Track everything for security and compliance:

# app/models/concerns/auditable.rb
module Auditable
  extend ActiveSupport::Concern
  
  included do
    after_create :log_creation
    after_update :log_update
    after_destroy :log_destruction
  end
  
  private
  
  def log_creation
    AuditLog.create(
      auditable: self,
      action: 'create',
      changes: attributes,
      user_id: Current.user&.id,
      ip_address: Current.ip_address
    )
  end
  
  # Similar for update and destroy...
end

# Usage
class OauthAccessToken < ApplicationRecord
  include Auditable
  # Now all token operations are logged
end

4. HTTPS Enforcement

OAuth2 requires HTTPS in production:

# config/environments/production.rb
config.force_ssl = true

# And validate redirect URIs
def validate_redirect_uri
  uri = URI.parse(params[:redirect_uri])
  
  unless uri.scheme == 'https' || Rails.env.development?
    redirect_to oauth_error_url('invalid_request',
      'Redirect URI must use HTTPS')
  end
end

How It Survived Three Rails Versions

Remember from Post #1 that we upgraded through Rails 6.1 → 7.0 → 7.1 → 8.0? Here’s the remarkable thing: Heis Soma’s OAuth2 implementation barely changed.

Rails 6.1 → 7.0

Changes required: Minimal

# Updated session handling slightly
# Old (Rails 6.1)
session[:oauth_state] = SecureRandom.hex(16)

# New (Rails 7.0) - same code, just works better
session[:oauth_state] = SecureRandom.hex(16)
# Rails 7's improved session handling made this more secure automatically

Why so smooth?

  • We followed Rails conventions
  • No Webpacker dependencies (API-only for OAuth endpoints)
  • Token generation was pure Ruby, not framework-specific
  • Database schema was simple and standard

Rails 7.0 → 7.1

Changes required: None for core OAuth flow

Improvements we adopted:

# Async queries for dashboard
def index
  @applications = current_user.oauth_applications.load_async
  @active_tokens = current_user.oauth_access_tokens.active.load_async
  
  # Both queries run in parallel now
end

Rails 7.1 → 8.0

Changes required: Still minimal

New features we’re evaluating:

  • Solid Queue for token cleanup jobs
  • Better connection pooling for high-traffic periods

The lesson? Standards-compliant code ages gracefully. Because we implemented OAuth2 by the spec rather than inventing our own approach, Rails upgrades didn’t break our authentication.

The Multiple Database Connection Win

This deserves special attention because it’s where our architecture really paid off.

The Problem (Rails 6.1)

When Prayer Nook needed user information:

# SLOW: HTTP request on every page load
def current_user_info
  response = HTTP.get(
    "https://heissoma.org/api/v1/user",
    headers: { "Authorization" => "Bearer #{session[:token]}" }
  )
  JSON.parse(response.body)
rescue
  nil
end

Performance cost:

  • 50-100ms for HTTP request
  • Additional DNS lookup
  • SSL handshake
  • Network latency
  • JSON parsing
  • Potential timeout/retry logic

Reliability issues:

  • Network failures
  • Heis Soma downtime affects Prayer Nook
  • Extra point of failure

The Solution (Rails 7+)

Direct database read access:

# config/database.yml (Prayer Nook)
production:
  primary:
    database: prayer_nook_production
    host: <%= ENV['PRIMARY_DB_HOST'] %>
    
  heis_soma:
    database: heis_soma_production
    host: <%= ENV['HEIS_SOMA_DB_HOST'] %>
    replica: true  # Read-only

# app/models/heis_soma/user.rb
module HeisSoma
  class User < ApplicationRecord
    self.abstract_class = true
    connects_to database: { writing: :heis_soma, reading: :heis_soma }
    
    # Read-only enforcement
    def readonly?
      true
    end
  end
end

# app/models/heis_soma/user_profile.rb
module HeisSoma
  class UserProfile < User
    self.table_name = "users"
    
    has_many :permissions
    has_many :prayer_groups
  end
end

# Now in controllers
def current_user_full_info
  @current_user ||= HeisSoma::UserProfile
    .includes(:permissions, :prayer_groups)
    .find_by(id: session[:user_id])
end

Performance improvement:

  • 50-100ms → 5-10ms (10x faster!)
  • No network calls
  • No HTTP overhead
  • Direct PostgreSQL query
  • Eager loading with includes

Results:

  • API calls to Heis Soma: -70%
  • Average page load: 245ms → 180ms
  • Fewer points of failure
  • Better caching possible
  • Simpler error handling

The Security Model

“Wait,” you might be thinking, “isn’t giving Prayer Nook direct database access to user data a security risk?”

Great question! Here’s how we handle it:

1. Read-Only Access

# Database user permissions
GRANT SELECT ON heis_soma.users TO prayer_nook_user;
GRANT SELECT ON heis_soma.permissions TO prayer_nook_user;
-- No INSERT, UPDATE, DELETE

# Enforced in Rails too
def readonly?
  true
end

# Any attempt to modify raises an error
user = HeisSoma::UserProfile.find(123)
user.email = "hacked@example.com"
user.save  # ActiveRecord::ReadOnlyRecord exception!

2. Network-Level Security

# Database firewall rules
# Only Prayer Nook servers can access Heis Soma DB
iptables -A INPUT -p tcp --dport 5432 -s <prayer-nook-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 5432 -j DROP

3. Separate Credentials

# Different database users for different apps
prayer_nook: read-only user with limited table access
unity_accord: read-only user with limited table access  
heis_soma: full access (only Heis Soma itself)

4. Sensitive Data Still via API

Some operations still go through the OAuth2 API:

  • Password changes
  • Email updates
  • Permission grants/revokes
  • Account deletion

Direct DB access is for reading user profiles and permissions, not modifying them.

When Should YOU Build Custom Auth?

After three years, here’s my honest assessment of when custom OAuth2 makes sense:

✅ Build Custom Auth When:

1. You Have Multiple Related Applications

  • SSO across your own services
  • Unified user experience
  • Shared session management

2. You Need OAuth2 Provider Capabilities

  • Third-party integrations planned
  • Partner API access
  • Mobile app support
  • Cross-platform requirements

3. You Have Specific Requirements

  • Unusual permission models
  • Custom user attributes
  • Industry-specific compliance
  • Theological/ethical constraints (like us!)

4. You Have Technical Capacity

  • Team understands security
  • Resources for maintenance
  • Can handle support burden
  • Willing to stay current on vulnerabilities

5. Long-Term Strategic Value

  • Authentication is core to your business
  • Competitive advantage in your approach
  • Plans for significant scale
  • Investment pays off over time

❌ Use Devise (or Alternatives) When:

1. Single Application

  • Only one Rails app
  • No SSO requirements
  • Simpler authentication needs

2. Standard Requirements

  • Email/password login
  • Password reset
  • Remember me
  • Session management
  • Standard stuff!

3. Limited Resources

  • Small team
  • Tight timeline
  • Other priorities more important
  • Can’t justify maintenance burden

4. Want Battle-Tested

  • Thousands of apps using it
  • Security issues found and fixed
  • Comprehensive documentation
  • Large community support

🤔 Consider Doorkeeper When:

Middle ground option:

  • Need OAuth2 provider
  • Want to use with Devise
  • Don’t want to build from scratch
  • Can handle some complexity

We actually tried Doorkeeper before going custom. It’s excellent software, but we found:

  • Still needed customization for our needs
  • Conceptual overlap with our desired architecture
  • Wanted deeper understanding of OAuth2 flows
  • Learning investment was same either way

Lessons Learned (The Real Talk)

What Worked Brilliantly

1. Standards Compliance Pays Off

Following RFC 6749 exactly meant:

  • Works with standard OAuth2 libraries
  • Easier to debug (compare to spec)
  • Future-proof against framework changes
  • Transferable knowledge for career

2. The Learning Investment Was Worth It

Building this taught me:

  • Deep understanding of authentication flows
  • Security thinking and threat modeling
  • API design principles
  • How to explain OAuth2 to stakeholders

This knowledge directly helped me in job interviews for Solutions Engineer roles. Being able to explain OAuth2 confidently, discuss security tradeoffs, and describe real production architecture is valuable.

3. Flexibility for Ministry Context

We could build features specific to our needs:

  • Prayer privacy levels
  • Ministry-specific permissions
  • Theological alignment verification
  • Church partnership models

These would have been hacks in Devise but are first-class in our system.

What Was Harder Than Expected

1. The Security Burden

You are responsible for protecting user credentials. This means:

  • Constant vigilance for vulnerabilities
  • Staying current on security best practices
  • Regular security audits
  • Stress about potential breaches

It’s serious responsibility. We handle it by:

  • Following OWASP guidelines
  • Regular dependency updates
  • Penetration testing (annual)
  • Insurance for security incidents

2. Maintenance Overhead

More code to maintain than Devise:

  • ~2,000 lines for OAuth2 implementation
  • Custom controllers and models
  • Integration tests for all flows
  • Documentation for team and partners

We budget ~5 hours/month for Heis Soma maintenance.

3. The “Am I Missing Something?” Anxiety

With Devise, thousands of apps have tested edge cases. With custom, you wonder:

  • Did I handle expired tokens correctly?
  • Is my PKCE implementation secure?
  • What about token revocation race conditions?
  • Am I compliant with the latest OAuth2 security BCP?

We mitigate this through:

  • Comprehensive test suite (95% coverage)
  • Security-focused code reviews
  • Following security mailing lists
  • Regular third-party audits

If I Could Go Back

What I’d do differently:

  1. Start with better documentation – We documented as we went, but starting with architecture docs would have helped.
  2. Implement PKCE from day one – We added it later for mobile apps. Should have been there from the start.
  3. Plan for token rotation earlier – We implemented this in year 2. Should have been v1 feature.
  4. Set up monitoring sooner – Authentication metrics are critical. We added comprehensive monitoring 6 months in.

What I’d do the same:

  1. Follow the spec religiously – This saved us during upgrades.
  2. Write extensive tests – Our test suite caught so many edge cases.
  3. Document everything – Future-me was very grateful.
  4. Take security seriously from day one – No shortcuts on auth.

The Future: Where Heis Soma Goes Next

We built Heis Soma to serve our ecosystem, but the vision is bigger:

Phase 1: Internal (Current)

  • ✅ Prayer Nook authentication
  • ✅ Unity Accord integration
  • ✅ Admin tools
  • ⏳ Events platform (Q2 2025)
  • ⏳ Small groups management (Q3 2025)

Phase 2: Partner Churches (2025-2026)

Imagine a small church with:

  • Their own website
  • A giving platform
  • An event registration tool
  • A volunteer coordination app

Currently: 4 different logins, 4 different passwords, 4 different user profiles.

With Heis Soma SSO:

  • One login across all tools
  • Unified member directory
  • Christian values-aligned provider
  • No surveillance/data mining
  • Church maintains control

We’re building this out now:

# Register a church as OAuth client
church = OauthApplication.create!(
  name: "First Baptist Phoenix",
  trust_level: :partner,
  redirect_uris: [
    "https://fbcphoenix.org/auth/callback",
    "https://giving.fbcphoenix.org/callback",
    "https://events.fbcphoenix.org/callback"
  ],
  scopes: [:basic, :email, :church_membership]
)

Phase 3: Public API (2026+)

Opening to broader ministry ecosystem:

  • Bible study apps
  • Devotional platforms
  • Ministry management tools
  • Christian social networks

The vision: A Christian alternative to “Login with Google” that:

  • Respects privacy
  • Aligns with faith values
  • Keeps data in ministry control
  • Serves the kingdom, not shareholders

Conclusion: Was It Worth It?

Three years later, I can definitively say: Yes, building Heis Soma was worth it.

Not because we’re smarter than the Devise team (we’re not). Not because our code is better (it’s not). But because our needs aligned with OAuth2 principles, and we had the capacity to do it right.

The decision to build custom authentication in 2021 looked crazy. In 2025, after three Rails upgrades, multiple application integrations, and 1,000+ users, it looks like strategic foresight.

The numbers:

  • Development time: ~200 hours initial, ~60 hours/year maintenance
  • Cost vs. Auth0/Okta: Saving ~$3,000/year
  • Performance improvement: 10x faster than HTTP API calls
  • Rails upgrades: Minimal authentication-related changes
  • Security incidents: Zero (knock on wood)

The intangibles:

  • Deep understanding of authentication
  • Confidence in our technical foundation
  • Ability to customize for ministry needs
  • Career skills (OAuth2 expertise)
  • Positioning for future growth

If you’re facing a similar decision, ask yourself:

  1. Do we have multiple related applications?
  2. Do we need OAuth2 provider capabilities?
  3. Do we have the technical capacity?
  4. Is this a long-term strategic investment?

If you answered yes to most of those, consider the custom route. If not, Devise is excellent and will serve you well.

Ember’s Closing Wisdom: “Sometimes the hardest path leads to the strongest foundation. Penguins may waddle awkwardly on land, but they’re masters in the water. Build for the environment where you’ll spend most of your time.” 🐧→🔥


Code Repository & Resources

Want to dive deeper? I’ve created:

📚 Resources:

🔗 Related Posts:

  • Post #1: Rails Migration Journey – How Heis Soma survived three Rails versions
  • Coming Next: Post #3: Multiple Database Connections – Deep dive into the Prayer Nook ↔ Heis Soma architecture

💬 Discussion:

  • What’s your authentication architecture?
  • Have you built custom auth? Share your story!
  • Considering OAuth2? Questions welcome in comments!

About This Series

This post is #2 in the Rails Renaissance series, exploring modern Ruby on Rails development through real-world applications serving a faith-based nonprofit. I’m Christopher “Topher” Warrington, bridging 20+ years of ministry with 20+ years of tech.

Philosophy: Build systems that serve people, write code that lasts, and never forget that behind every OAuth2 flow is a human being seeking connection.

Currently seeking Solutions Engineer roles where I can bridge complex technical systems and human needs. Let’s connect!


Tags: #OAuth2, #Rails8, #RubyOnRails, #Authentication, #SSO, #CustomAuth, #HeisSoma, #SoftwareSecurity, #APIDesign, #NonprofitTech

Published: March 31, 2025 | Reading Time: 14 minutes | Series: Rails Renaissance #2

Written By Topher Warrington

Related Posts

Monitoring Without Madness: APM for Rails Apps

Monitoring Without Madness: APM for Rails Apps

Application Performance Monitoring (APM) is the secret weapon for keeping your Rails apps fast, reliable, and user-friendly. In “Monitoring Without Madness: APM for Rails Apps,” I break down how faith-based platforms like Prayer Nook use APM tools to diagnose bottlenecks, prevent errors, and build user trust. From tracking slow queries and background job failures to ensuring smooth page loads during peak traffic, this post offers a practical guide to observability.
You’ll learn how to choose the right APM tools (Scout, New Relic, or Honeybadger), set up monitoring for Rails 8 apps, and track key metrics like response times, error rates, and database performance. Real-world examples, including a case study from Prayer Nook, demonstrate how APM can cut prayer wall load times by 70% and boost user satisfaction. Plus, we’ll explore the ethical side of monitoring—how to balance data collection with user trust and privacy.
If you’re ready to stop guessing and start debugging with confidence, this post is your roadmap to building a monitoring strategy that works—without the madness.

read more
AI-Assisted Accessibility: How To Make Faith Apps for Everyone

AI-Assisted Accessibility: How To Make Faith Apps for Everyone

Inclusion is more than a checkbox—it’s a calling. In “AI-Assisted Accessibility: Making Faith Apps for Everyone,” I share how Prayer Nook and our ministry platforms leveraged AI to break down real barriers faced by users with disabilities, language differences, and diverse learning needs. With AI-powered voice input, instant spiritual translation, screen reader optimization, adaptive UIs, and empathetic audio guides, we’ve opened the door for elderly users, those with visual or motor challenges, and non-English speakers to fully participate in our digital faith communities.
This post goes beyond technical checklists to reveal the human stories behind accessibility: Anna, who can now pray aloud despite arthritis; Maria, whose Portuguese prayer reached an English-speaking friend; Sam, who found focus through a neurodivergent-friendly “simple mode.” Alongside code samples and real-world lessons, you’ll find practical Rails 8 integration patterns, prompt engineering for spiritual nuance, and honest talk about the ethical limits of AI.
The journey hasn’t been perfect—accents stumped our models, AI hallucinated scripture, and early TTS voices sounded robotic—but persistent iteration, transparency, and user feedback kept us moving forward. Most importantly, we learned that AI is a tool, not a replacement for human discernment or compassion. Accessibility, powered by AI, is about building ramps—digital and spiritual—so everyone can belong, participate, and be transformed.
If you’re building ministry or community software, this is your roadmap for making tech a true bridge, not a barrier. Let’s keep widening the circle—together.

read more

0 Comments

Submit a Comment

Your email address will not be published. Required fields are marked *