WebAuthn in Rails: Design Decisions and Trade-offs

Explore the architectural choices behind our Rails WebAuthn implementation

Published

Published

Feb 18, 2026

Feb 18, 2026

Topic

Topic

Engineering

Engineering

Written by

Written by

Ali Fadel, Ibraheem Tuffaha

Ali Fadel, Ibraheem Tuffaha

WebAuthn in Rails: Design Decisions and Trade-offs

This is Part 4 of a 4-part series on implementing WebAuthn in Rails with Devise and Inertia.js.

Part

Title

1

Setup and Registration

2

Authentication: From 2FA to Password-less

3

Backup Codes: The Safety Net

4

Design Decisions and Trade-offs (you are here)

Over the previous three posts, we built a complete WebAuthn implementation: passkey registration, password-less authentication, and backup codes, but building a production-ready system isn't just about writing code; it's about making deliberate security trade-offs. In this final post, we'll step back and discuss the design decisions we made, trade-offs we accepted, and edge cases we chose not to handle.

Why We Built Incrementally

We started with password-full 2FA, then added password-less, then backup codes. We chose this path to provide a comprehensive comparison of both implementation strategies.

The password-full flow allowed us to demonstrate the standard 2FA pattern first, ensuring we covered how WebAuthn integrates alongside traditional passwords. We then evolved this into a password-less flow to showcase the simplified UX and cleaner architecture it enables.

Once the password-less implementation was complete, we removed the transitional infrastructure entirely. The enforce_web_authn_verification concern became unnecessary — users with passkeys now use the password-less flow, while others fall back to passwords. The result is simpler code with a better user experience and the same security.

Session-Based Challenge Storage

We store WebAuthn challenges in the Rails session rather than a database table. This is simpler and works fine for our use case.

The tradeoff: challenges are tied to the browser session. If a user has multiple tabs open, only one can complete registration at a time. For most apps, this isn't a problem. If you need concurrent registrations from the same user, you'd need database-backed challenges.

BCrypt for Backup Codes

We hash backup codes with BCrypt (cost factor 12) rather than storing them in plaintext. This adds protection if the database is compromised.

There are tradeoffs. First, we can't show users their existing codes. They have to regenerate if they lose them. We decided this is a reasonable security/UX trade-off. The alternative (showing existing codes) would require storing them in a reversible format, which defeats the purpose.

Second, BCrypt with cost 12 is deliberately slow. Verifying a single code takes roughly 250ms on modern hardware. Since we have to check up to 10 codes when a user enters one (we don't know which code they're using), verification can take up to 2.5 seconds in the worst case. We decided this is acceptable because backup code usage is rare and the security benefit is significant.

Rate Limiting Strategy

We apply rate limits at multiple levels:

  1. Rack-level: 5 requests per minute per IP for sensitive endpoints

  2. Application-level: 10 failed backup code attempts triggers 30-minute lockout

This layered approach prevents both automated attacks (Rack limit) and manual brute-forcing (application limit).

User Verification Settings

We use different user_verification settings depending on the authentication model:

  • 2FA (password + passkey): 'discouraged' - The password already proves identity, so the passkey only needs to prove possession (touch).

  • Password-less: 'required' - The passkey must prove both possession AND identity (PIN/biometric).

If you're implementing 2FA only, stick with 'discouraged'. If you're implementing password-less from the start, use 'required'. The 'preferred' option is a middle ground that verifies if the authenticator supports it but doesn't fail if it doesn't.

Always Remember Me

We made a deliberate decision to always set remember: true when signing in users, regardless of how they authenticate. We removed the "remember me" checkbox entirely.

The reasoning: users who authenticate with passkeys have proven possession of a secure device. The risk of session theft is low, and the UX benefit of not having to re-authenticate constantly is high. For password-only users, we still set remember_me because modern session management (secure cookies, reasonable expiry) is good enough for most apps.

If your security requirements are stricter, you might want to reconsider this.

Race Conditions We Chose Not to Handle

Every system has edge cases. Here are some we decided weren't worth the complexity to handle:

Concurrent First-Passkey Registration

If a user opens two tabs and registers their first passkey in both simultaneously, they might see the backup codes modal twice or get duplicate backup codes generated. We decided this is rare enough that adding distributed locking isn't worth it. The worst case is they get two sets of backup codes, and only the second set is valid.

Backup Code Consumed During Account Lock

A user's Devise account could get locked between when we verify their backup code and when we call mark_as_used!. We check active_for_authentication? before marking the code used, but there's still a tiny window.

In practice, this race is nearly impossible to hit. For it to matter, someone would need to trigger account lockout in the milliseconds between verification and consumption. We decided the complexity of handling this wasn't justified.

Session Expiry During WebAuthn Ceremony

If the Rails session expires while the user is interacting with their authenticator (fingerprint prompt, Face ID), the subsequent request will fail with an expired challenge error. We show a generic "session expired" message and redirect to sign-in.

A more sophisticated solution would refresh the session client-side or use longer-lived challenges. We decided the added complexity isn't worth it for this rare edge case.

Credential Sign Count Rollback

WebAuthn tracks how many times a credential has been used. If verification succeeds but the database update fails, the sign count won't increment. This could theoretically let a cloned authenticator go undetected for one authentication.

We decided this is an acceptable risk given the low probability of both cloned authenticators and database failures occurring together.

Implementation Statistics

Before deciding whether to build this yourself or use a SaaS solution, here's what the implementation actually involved:

Code Volume

Category

New Files

Modified Files

Total Lines

Ruby

602

+102

~704

Vue

985

+134

~1,119

Total



~1,823

Breaking down the new Ruby files:

Component

Lines

Controllers

312

Models

94

Services

42

Tests

92

Migrations

43

Config

19

Files

Type

New Files

Modified Files

Ruby

20

8

Vue

6

10

New files:

  • 10 controllers (registration, authentication, backup codes)

  • 2 models (WebAuthnCredential, BackupCode)

  • 1 service (BackupCodes::Generator)

  • 1 concern (PendingAuthentication)

  • 5 migrations + 1 initializer

  • 4 Vue components + 2 Vue pages

API Surface

11 new endpoints:

  • 4 for passkey registration and management

  • 4 for passkey authentication

  • 2 for backup codes

  • 1 for email lookup (password-less flow)

Development Time

This implementation took 7 days of active development. That includes:

  • Research and prototyping

  • Implementation of all features

  • Testing and debugging

  • Code review and refinements

  • And writing the blog posts!

Build vs Buy Considerations

Arguments for building it yourself:

  • Full control over the UX and flow

  • No per-user or per-authentication fees

  • No vendor lock-in

  • Deeper understanding of your auth system

  • Can customize to your exact needs

Arguments for using a SaaS:

  • Faster time to market

  • Battle-tested implementation

  • Ongoing maintenance handled for you

  • Security updates and compliance

  • Support for edge cases you haven't thought of

Our take: If authentication is core to your product and you have the engineering capacity, building it yourself is worth considering. The webauthn gem handles the hard cryptographic parts. What you're really building is session management and UX. If you're a small team (We are already a small team!) or auth isn't your focus, a SaaS might be the better choice.

Lessons Learned

After building this, here's what we'd do differently next time:

  1. Start with password-less from day one. We built 2FA first to test both solutions, but if we were starting fresh, we'd skip straight to password-less. The code is simpler (no enforce_web_authn_verification dance), and users get the full benefit immediately.

  2. Test on real devices earlier. The browser's WebAuthn API behaves differently across devices. What works in Chrome on macOS might fail on an Android phone. We caught issues late that would have been obvious with earlier cross-device testing.

  3. Don't over-engineer session management. Our first implementation had complex session tracking for 2FA. When we switched to password-less, most of that code went away. Keep it simple until you have a reason not to.

  4. User verification settings matter more than you think. The difference between 'required', 'preferred', and 'discouraged' has real UX implications. Understand the trade-offs before choosing.

Conclusion

WebAuthn isn't trivial to implement, but it's not as scary as it looks. The key is understanding that it's a three-party protocol (browser, authenticator, server) and that your job is mostly coordination.

The webauthn gem and @simplewebauthn/browser handle the cryptographic heavy lifting. Your job is to manage sessions correctly, store credentials securely, and create a UX that doesn't confuse users.

Remember: the frontend implementation here is specific to our app. Your sign-in flow will look different. The patterns are transferable, but you'll need to adapt them to your design.

The result is authentication that's both more secure and more convenient. Your users won't thank you because they'll barely notice. They'll just stop forgetting passwords.

Series Summary

Part

What You'll Build

Part 1: Setup and Registration

WebAuthn gem configuration, database schema, models, and passkey registration flow

Part 2: Authentication

2FA with passkeys, password-less authentication, and the adaptive sign-in flow

Part 3: Backup Codes

Backup code generation, authentication, regeneration, and the safety net for lost passkeys

Part 4: Design Decisions

Trade-offs, edge cases, and architectural insights

Previous: Part 3 - Backup Codes: The Safety Net