Unmagic::Passkeys
Passkey (WebAuthn) authentication for Rails, backed by Active Record, with no external dependencies. The WebAuthn ceremonies — CBOR decoding, COSE key parsing, attestation and assertion verification — are implemented in pure Ruby on top of stdlib OpenSSL. Challenges are stateless (signed, expiring tokens), so there's no server-side challenge storage.
Provenance
This gem is extracted from the fizzy codebase,
where the WebAuthn/passkey implementation lives vendored under lib/action_pack/ as
ActionPack::Passkey / ActionPack::WebAuthn. By the looks of it (the ActionPack::
namespace, the railtie wiring, the omakase style) it's on its way to becoming a
first-class Rails feature eventually.
Until that lands and ships, this is the extracted, standalone version — the same
code lifted out, renamed under Unmagic::Passkeys, and packaged as a self-contained
Rails engine you can drop into an app today. If/when an official Rails passkeys API
arrives, prefer it; this gem exists to bridge the gap in the meantime.
See NOTICE for attribution.
Installation
# Gemfile
gem "unmagic-passkeys"
bin/rails generate unmagic:passkeys:install # copies the migration, wires the JS
bin/rails db:migrate
Holder model
Declare which model owns passkeys with has_passkeys. name is the account
identifier shown by the authenticator; display_name is a friendly label.
class User < ApplicationRecord
has_passkeys name: :email_address, display_name: :name
end
This adds a polymorphic has_many :passkeys association and
passkey_registration_options / passkey_authentication_options.
API
# Registration ceremony
= Unmagic::Passkeys.(holder: user) # -> pass to navigator.credentials.create()
passkey = user.passkeys.register(params[:passkey]) # verifies attestation, persists
# Authentication ceremony
= Unmagic::Passkeys. # -> pass to navigator.credentials.get()
passkey = Unmagic::Passkeys.authenticate(params[:passkey]) # verified credential, or nil
The engine mounts a stateless challenge endpoint at POST /unmagic/passkeys/challenge
(passkey_challenge_path), which the JavaScript refreshes before each ceremony.
Host wiring
The engine ships the primitives; your app owns the login/registration controllers and views. The form helpers render self-contained web components — include the JS once:
// app/javascript/application.js
import "unmagic/passkeys"
Sign in (app/views/sessions/new.html.erb):
<%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path,
options: @authentication_options, mediation: "conditional" %>
class Sessions::PasskeysController < ApplicationController
include Unmagic::Passkeys::Request
def create
if credential = Unmagic::Passkeys.authenticate(passkey_authentication_params)
start_new_session_for credential.holder
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "That passkey didn't work."
end
end
end
Register (signed-in):
<%= passkey_registration_button "Register a passkey", passkeys_path,
options: @registration_options %>
class PasskeysController < ApplicationController
include Unmagic::Passkeys::Request # sets the WebAuthn request context + param helpers
def index
@registration_options = (holder: Current.user)
end
def create
Current.user.passkeys.register(passkey_registration_params)
redirect_to passkeys_path, notice: "Passkey added."
end
end
Unmagic::Passkeys::Request provides passkey_registration_params,
passkey_authentication_params, passkey_registration_options,
passkey_authentication_options, and sets Unmagic::Passkeys::WebAuthn::Current
(host/origin) per request.
Configuration
# config/initializers/passkeys.rb
Rails.application.configure do
config.unmagic_passkeys.web_authn. = { attestation: :none }
config.unmagic_passkeys.web_authn. = { user_verification: :required }
config.unmagic_passkeys.web_authn.creation_challenge_expiration = 10.minutes
config.unmagic_passkeys.web_authn.request_challenge_expiration = 5.minutes
# config.unmagic_passkeys.parent_class_name = "ApplicationRecord"
end
Development
bundle install
bundle exec rspec # specs (incl. a full register→authenticate round-trip)
bundle exec rubocop
Supported algorithms: ES256 (P-256), EdDSA (Ed25519), RS256. Only the none
attestation format is verified by default; register others with
Unmagic::Passkeys::WebAuthn.register_attestation_verifier.
License
MIT — see LICENSE. Attribution in NOTICE.