1. Home
  2. Work
  3. Contact
  4. Blog

Im Obstgarten 7
8596 Scherzingen
Switzerland


(CET/CEST, Mo-Fr, 09:00 - 18:00)

IMEOS on GitHub
Rails Devise

Implementing Passwordless Authentication with WebAuthn and Passkeys in Rails

02 Nov 2025
9 minutes read

A comprehensive guide to adding passkey support to your Rails + Devise application

Modern authentication is moving beyond passwords. Passkeys, built on the WebAuthn standard, offer a more secure and user-friendly alternative. This post walks through implementing a complete passkey authentication system in a Rails application using Devise, including web and mobile support.

The Challenge

While the WebAuthn specification is well-documented, integrating it into a production Rails application with Devise presents several challenges:

  • Supporting both passwordless login and 2FA scenarios
  • Implementing email-less (usernameless) authentication flows
  • Providing REST API endpoints
  • Adding proper security measures against brute force attacks
  • Managing the complexity of credential storage and verification

Here’s how to build a complete solution with Devise and the Webauthn gem.

Phase 1: Foundation and Setup

Start by adding the webauthn gem to your Gemfile:

gem 'webauthn'

Create the WebauthnCredential model to store user credentials:

# app/models/webauthn_credential.rb
class WebauthnCredential < ApplicationRecord
  belongs_to :user
  
  validates :external_id, presence: true, uniqueness: true
  validates :public_key, presence: true
  validates :sign_count, presence: true
end

The migration needs to store the credential’s public key, sign count (for replay attack prevention), and metadata:

create_table :webauthn_credentials do |t|
  t.references :user, null: false, foreign_key: true
  t.string :external_id, null: false
  t.string :public_key, null: false
  t.bigint :sign_count, null: false, default: 0
  t.string :nickname
  t.datetime :last_used_at
  t.timestamps
end

Configure WebAuthn with your relying party information:

# config/initializers/webauthn.rb
WebAuthn.configure do |config|
  config.origin = ENV.fetch('WEBAUTHN_ORIGIN', 'https://www.yourdomain.com')
  config.rp_name = 'Name that users see in system dialogues'
  config.rp_id = ENV.fetch('WEBAUTHN_RP_ID', 'www.yourdomain.com')
end

Phase 2: Email-less Passkey Login

The most elegant passkey implementation is usernameless authentication. Users simply click “Sign in with passkey” without needing to enter an email address:

# app/controllers/webauthn_sessions_controller.rb
class WebauthnSessionsController < ApplicationController
  def new
    # Generate challenge for authentication
    options = WebAuthn::Credential.options_for_get(
      allow: [],  # Empty array enables resident key flow
      user_verification: 'required'
    )
    
    session[:webauthn_challenge] = options.challenge
    render json: options
  end
  
  def create
    webauthn_credential = WebAuthn::Credential.from_get(params)
    
    stored_credential = WebauthnCredential.find_by(
      external_id: webauthn_credential.id
    )
    
    begin
      webauthn_credential.verify(
        session[:webauthn_challenge],
        public_key: stored_credential.public_key,
        sign_count: stored_credential.sign_count
      )
      
      stored_credential.update!(
        sign_count: webauthn_credential.sign_count,
        last_used_at: Time.current
      )
      
      sign_in(stored_credential.user)
      render json: { success: true }
    rescue WebAuthn::Error => e
      render json: { error: e.message }, status: :unprocessable_entity
    end
  end
end

The JavaScript integration handles the browser WebAuthn API:

// app/frontend/javascripts/webauthn.js
async function authenticateWithPasskey() {
  try {
    const optionsResponse = await fetch('/webauthn_sessions/new');
    const options = await optionsResponse.json();
    
    // Convert base64 challenge to ArrayBuffer
    options.challenge = base64ToArrayBuffer(options.challenge);
    
    const credential = await navigator.credentials.get({
      publicKey: options
    });
    
    const response = await fetch('/webauthn_sessions', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: credential.id,
        rawId: arrayBufferToBase64(credential.rawId),
        response: {
          authenticatorData: arrayBufferToBase64(credential.response.authenticatorData),
          clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
          signature: arrayBufferToBase64(credential.response.signature),
          userHandle: arrayBufferToBase64(credential.response.userHandle)
        },
        type: credential.type
      })
    });
    
    if (response.ok) {
      window.location.href = '/';
    }
  } catch (error) {
    console.error('Passkey authentication failed:', error);
  }
}

Phase 3: Passkey Management Interface

Users need to register and manage their passkeys:

# app/controllers/settings/users/passkeys_controller.rb
class Settings::Users::PasskeysController < ApplicationController
  before_action :authenticate_user!
  
  def index
    @passkeys = current_user.webauthn_credentials
  end
  
  def new
    options = WebAuthn::Credential.options_for_create(
      user: {
        id: current_user.id.to_s,
        name: current_user.email,
        display_name: current_user.email
      },
      exclude: current_user.webauthn_credentials.pluck(:external_id)
    )
    
    session[:webauthn_creation_challenge] = options.challenge
    render json: options
  end
  
  def create
    webauthn_credential = WebAuthn::Credential.from_create(params)
    
    webauthn_credential.verify(session[:webauthn_creation_challenge])
    
    current_user.webauthn_credentials.create!(
      external_id: webauthn_credential.id,
      public_key: webauthn_credential.public_key,
      sign_count: webauthn_credential.sign_count,
      nickname: params[:nickname]
    )
    
    render json: { success: true }
  rescue WebAuthn::Error => e
    render json: { error: e.message }, status: :unprocessable_entity
  end
  
  def destroy
    passkey = current_user.webauthn_credentials.find(params[:id])
    passkey.destroy
    redirect_to settings_users_passkeys_path, notice: 'Passkey removed'
  end
end

Phase 4: Mobile API Support

For mobile apps, create dedicated API endpoints:

# app/controllers/api/v1/webauthn_controller.rb
class Api::V1::WebauthnController < Api::V1::BaseController
  def challenge
    options = WebAuthn::Credential.options_for_get(
      allow: [],
      user_verification: 'required'
    )
    
    # Store challenge in cache with short expiry
    Rails.cache.write(
      "webauthn_challenge:#{options.challenge}",
      options.challenge,
      expires_in: 5.minutes
    )
    
    render json: {
      challenge: options.challenge,
      timeout: options.timeout,
      rpId: WebAuthn.configuration.rp_id,
      userVerification: options.user_verification
    }
  end
  
  def authenticate
    challenge = Rails.cache.read("webauthn_challenge:#{params[:challenge]}")
    
    return render_error('Invalid or expired challenge', :unauthorized) unless challenge
    
    webauthn_credential = WebAuthn::Credential.from_get(params[:credential])
    stored_credential = WebauthnCredential.find_by(external_id: webauthn_credential.id)
    
    return render_error('Credential not found', :unauthorized) unless stored_credential
    
    webauthn_credential.verify(
      challenge,
      public_key: stored_credential.public_key,
      sign_count: stored_credential.sign_count
    )
    
    stored_credential.update!(
      sign_count: webauthn_credential.sign_count,
      last_used_at: Time.current
    )
    
    token = generate_auth_token(stored_credential.user)
    render json: { token: token, user: stored_credential.user }
  rescue WebAuthn::Error => e
    render_error(e.message, :unauthorized)
  end
end

Configure allowed origins for mobile platforms:

# config/initializers/webauthn.rb
WebAuthn.configure do |config|
  config.origin = lambda do |rp_id|
    [
      ENV.fetch('WEBAUTHN_ORIGIN', 'https://www.yourdomain.com'),
    'https://www.yourdomain.com',
      # Android APK signature hash
      'android:apk-key-hash:YOUR_APK_HASH_HERE'
    ]
  end
  config.rp_id = ENV.fetch('WEBAUTHN_RP_ID', 'www.yourdomain.com')
end

Phase 5: Security Hardening

Implement basic security measures to protect against attacks:

# config/initializers/rack_attack.rb
class Rack::Attack
  throttle('webauthn/ip', limit: 5, period: 1.minute) do |req|
    if req.path.start_with?('/webauthn') || req.path.include?('/api/v1/webauthn')
      req.ip
    end
  end
  
  blocklist('block banned IPs') do |req|
    BlockedIp.exists?(ip_address: req.ip)
  end
end

Create an authentication logger service:

# app/services/auth_logger.rb
class AuthLogger
  def self.log_attempt(user:, method:, success:, ip:, user_agent:)
    Rails.logger.info({
      event: 'auth_attempt',
      user_id: user&.id,
      method: method,
      success: success,
      ip: ip,
      user_agent: user_agent,
      timestamp: Time.current
    }.to_json)
    
    AuthAttackDetector.check(ip: ip) unless success
  end
end

Implement attack detection:

# app/services/auth_attack_detector.rb
class AuthAttackDetector
  FAILURE_THRESHOLD = 10
  TIME_WINDOW = 1.hour
  
  def self.check(ip:)
    key = "auth_failures:#{ip}"
    failures = Rails.cache.increment(key, 1, expires_in: TIME_WINDOW)
    
    if failures >= FAILURE_THRESHOLD
      BlockedIp.create!(ip_address: ip, reason: 'Brute force detected')
      AlertJob.perform_later('brute_force_detected', ip: ip, failures: failures)
    end
  end
end

Testing

Some test coverage won’t harm:

# test/controllers/webauthn_sessions_controller_test.rb
class WebauthnSessionsControllerTest < ActionDispatch::IntegrationTest
  test "should generate authentication challenge" do
    get new_webauthn_session_url
    assert_response :success
    
    json = JSON.parse(response.body)
    assert json['challenge'].present?
    assert_equal session[:webauthn_challenge], json['challenge']
  end
  
  test "should authenticate with valid credential" do
    user = users(:one)
    credential = webauthn_credentials(:one)
    
    # Mock WebAuthn verification
    WebAuthn::Credential.stub(:from_get, mock_credential) do
      post webauthn_sessions_url, params: { /* credential data */ }
      assert_response :success
      assert_equal user.id, session[:user_id]
    end
  end
end

Results

This implementation provides:

  • Passwordless authentication: Users can sign in without passwords using biometrics or security keys
  • 2FA support: Passkeys work as a second factor alongside traditional passwords
  • Cross-platform: Works on web browsers and via a simple REST API
  • Security: Rate limiting, attack detection, and IP blocking prevent abuse
  • User-friendly: Email-less login flow reduces friction
  • Well-tested: Comprehensive test coverage ensures reliability

The phased approach allowed for iterative improvements while maintaining a working system at each stage. Starting with basic functionality and progressively adding mobile support, security hardening, and UX refinements proved effective.

Key Learnings

  1. Start with usernameless flow: Email-less authentication provides the best UX but requires resident key support
  2. Mobile requires different origins: Android and iOS need specific origin configurations
  3. Security is critical: Rate limiting and attack detection are essential for production
  4. Test thoroughly: WebAuthn’s complexity demands comprehensive testing
  5. Configuration flexibility: Environment-based configuration helps across dev/staging/production

Passkeys represent the future of authentication. With proper implementation, they offer superior security and user experience compared to traditional passwords.


  • Privacy
  • Imprint