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
- Start with usernameless flow: Email-less authentication provides the best UX but requires resident key support
- Mobile requires different origins: Android and iOS need specific origin configurations
- Security is critical: Rate limiting and attack detection are essential for production
- Test thoroughly: WebAuthn’s complexity demands comprehensive testing
- 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.