Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Ruby on Rails  Module 6 – Authentication and authorization

MFA, passkeys and social login

Add TOTP two-factor authentication with recovery codes, passkeys with WebAuthn, and OAuth social login that blocks login CSRF.

  • Advanced
  • 10 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Add TOTP two-factor authentication with recovery codes
  • Explain passkey registration and sign-in ceremonies
  • Add OAuth sign-in without opening a login-CSRF hole
  • Choose between the generator, Devise and Rodauth for a given app

Before you start

Passwords alone leave accounts open to phishing and credential stuffing. When a user’s password appears in a leaked database, anyone who finds it can sign in. Multi-factor authentication stops that: even with the password, an attacker still needs a second factor the user carries. Passkeys go further by replacing the password entirely with a key pair on the user’s device. Social login with OAuth lets users sign in with a provider they trust, but only when the Rails app checks a token on the POST that starts the flow; without that check, an attacker can forge the sign-in and bind their own provider account to the victim’s session.

This lesson adds TOTP (Time-Based One-Time Password) two-factor authentication with recovery codes, explains passkey registration and sign-in ceremonies, and shows how to add OAuth sign-in without opening a login CSRF: An attack where a forged request starts the OAuth flow with the attacker's provider identity, binding it to the victim's session so the attacker can access anything the victim does. hole. It ends with a comparison of the Rails 8 authentication generator, Devise and Rodauth for different app sizes.

Not affiliated

MySmartCoPilot is not affiliated with the maintainers of Devise, Rodauth or OmniAuth.

TOTP adds a second factor from a shared secret and the clock

TOTP: Time-based One-Time Password: a six-digit code derived from a shared secret and the current 30-second Unix time step, used as a second factor. is the second factor most users know: an authenticator app such as Google Authenticator or Authy shows a six-digit code that changes every 30 seconds. The server and the app share a secret; both compute the same code from that secret and the current Unix time divided by 30.

The rotp gem (6.3.0) implements RFC 6238 in Ruby:

TOTP enrolment with rotp Ruby · totp_enrolment.rb
# TOTP enrolment with rotp: secret generation, provisioning URI, code verification
# and recovery codes stored as SHA-256 digests.
require "rotp"
require "digest"

# --- TOTP secret and provisioning ---
# A real app calls ROTP::Base32.random, which reads SecureRandom and so differs every time. This page needs one
# fixed output, so the secret is drawn from a seeded generator over the Base32 alphabet instead. Never do this in
# an app: a predictable secret is a predictable second factor.
BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".freeze
secret_rng = Random.new(20_261_018)
base32_secret = Array.new(26) { BASE32_ALPHABET[secret_rng.rand(32)] }.join
puts "Base32 secret: #{base32_secret}"

totp = ROTP::TOTP.new(base32_secret, issuer: "Kaksha")
provisioning_uri = totp.provisioning_uri("teacher@kaksha.example")
puts "Provisioning URI: #{provisioning_uri}"

# --- Verify a code at a fixed time ---
# Unix timestamp 1700000000 is 2023-11-14 22:13:20 UTC
fixed_time = Time.at(1700000000)
code_at_fixed_time = totp.at(fixed_time)
puts "Code at #{fixed_time.utc}: #{code_at_fixed_time}"

# Verify the code at that exact time
verified = totp.verify(code_at_fixed_time, at: fixed_time)
puts "Verified at exact time: #{verified.nil? ? 'no' : 'yes'}"

# --- Drift: accept a code from the previous 30-second step ---
# 1700000000 sits in the step that starts at 1699999980 (1700000000 - 1700000000 % 30).
# The step before it starts at 1699999950, so 1699999975 is inside the PREVIOUS step.
previous_step_time = Time.at(1699999975)
code_previous_step = totp.at(previous_step_time)
puts "Code from previous step (#{previous_step_time.utc}): #{code_previous_step}"

# Without drift, verification at the fixed time fails
verified_no_drift = totp.verify(code_previous_step, at: fixed_time)
puts "Verified previous-step code without drift: #{verified_no_drift.nil? ? 'no' : 'yes'}"

# With drift_behind: 30, it succeeds
verified_with_drift = totp.verify(code_previous_step, at: fixed_time, drift_behind: 30)
puts "Verified previous-step code with drift_behind=30: #{verified_with_drift.nil? ? 'no' : 'yes'}"

# --- Recovery codes: stored as SHA-256 digests, single-use ---
RecoveryCode = Struct.new(:digest, :used) do
  def self.generate(count = 10)
    count.times.map do |i|
      # In production: SecureRandom.alphanumeric(12)
      # Here we use a predictable value for determinism
      plain = "RECOVERY-#{i.to_s.rjust(4, '0')}"
      new(Digest::SHA256.hexdigest(plain), false)
    end
  end

  def matches?(plain)
    Digest::SHA256.hexdigest(plain) == digest
  end

  def consume!
    self.used = true
  end
end

codes = RecoveryCode.generate(3)
puts "\nRecovery codes (show once, never again):"
3.times { |i| puts "  RECOVERY-#{i.to_s.rjust(4, '0')}" }
puts "Stored digests: #{codes.map { |c| c.digest[0..15] + '...' }.join(', ')}"

# Attempt to use a recovery code
attempt = "RECOVERY-0001"
matched = codes.find { |c| !c.used && c.matches?(attempt) }
if matched
  matched.consume!
  puts "\nRecovery code #{attempt} accepted and consumed."
else
  puts "\nRecovery code #{attempt} rejected (invalid or already used)."
end

# Second attempt with the same code fails
matched_again = codes.find { |c| !c.used && c.matches?(attempt) }
puts "Second use of #{attempt}: #{matched_again ? 'accepted' : 'rejected (already used)'}"

Output

Base32 secret: E6KMBF6KDVIJLYG232SLC2DGR4
Provisioning URI: otpauth://totp/Kaksha:teacher%40kaksha.example?secret=E6KMBF6KDVIJLYG232SLC2DGR4&issuer=Kaksha
Code at 2023-11-14 22:13:20 UTC: 521373
Verified at exact time: yes
Code from previous step (2023-11-14 22:12:55 UTC): 159710
Verified previous-step code without drift: no
Verified previous-step code with drift_behind=30: yes

Recovery codes (show once, never again):
  RECOVERY-0000
  RECOVERY-0001
  RECOVERY-0002
Stored digests: e93f5082f1e07de1..., d018d77fffdbf936..., 78f34b00cc969f83...

Recovery code RECOVERY-0001 accepted and consumed.
Second use of RECOVERY-0001: rejected (already used)

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby totp_enrolment.rb

The example creates a secret, builds the provisioning URI an authenticator app scans, verifies a code at a fixed time and shows that a code from the previous 30-second step is accepted with drift_behind: 30. It then demonstrates recovery codes: stored as SHA-256 digests, each can be used exactly once.

Enrolment stores the secret only after a valid code

A user enables TOTP in their account settings. The server:

  1. Generates a random secret and stores it in the user’s session (not yet in the database).
  2. Shows a QR code of the provisioning URI and asks the user to scan it.
  3. Asks the user to enter the code the app shows.
  4. If the code is valid, encrypts that secret with Active Record Encryption: Rails' built-in column encryption with encrypts: the ciphertext is stored in the database and decrypted with keys from credentials, so a database leak does not expose the plain values. and stores it in the database.
Interactive · message by message TOTP enrolment in Kaksha Follow the four steps from secret generation to storing the encrypted secret
TOTP enrolment in Kaksha, step 8 of 8. Messages so far: User to Rails: Enable TOTP; Rails to User: QR code; User to Authenticator: Scan QR; Authenticator to User: 123456; User to Rails: Submit code; Rails to User: TOTP enabled.UserRailssecret encryptedAuthenticatorsecret storedEnable TOTPQR codeScan QR123456Submit codeTOTP enabled

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Enable TOTP. The user opens account settings and taps Enable two-factor authentication.
  2. Generate secret. Rails generates a random Base32 secret with ROTP::Base32.random. It keeps that secret in the session, not yet in the database.
  3. Show QR code. Rails builds a provisioning URI and renders a QR code. The user scans it with their authenticator app.
  4. App stores secret. The authenticator app decodes the URI and keeps a copy. It starts showing six-digit codes that change every 30 seconds.
  5. Enter code. The user reads the current code from the app and types it into the form.
  6. Verify code. The user submits the code. Rails computes the expected code from the session secret and the current time. If it matches, the server accepts the code.
  7. Store encrypted secret. Rails encrypts that secret with Active Record Encryption and writes it to the user's record. It clears the session copy and marks TOTP as enabled.
  8. Confirm to user. Rails shows a confirmation message. The user now needs both their password and a code from the app to sign in.

Key idea

A TOTP secret is as powerful as a private key: anyone who has it can generate valid codes, so store it encrypted at rest and never log it.

Checkpoint

Check: TOTP and recovery codes

  1. Question 1 of 2 Why does a TOTP verifier allow a code from the previous 30-second step?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Clock skew: the user's device may be a few seconds behind

    A drift window accepts codes from the previous (or next) 30-second step to handle small clock differences between the server and the user's device.

  2. Question 2 of 2 A developer stores the TOTP secret in plain text. An attacker who gains read access to the database can now do what?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Generate valid TOTP codes for any user whose secret they read

    The TOTP secret is the private key of the scheme. Store it encrypted at rest with Active Record Encryption.

Recovery codes are hashed and spent once

A user who loses their phone cannot generate TOTP codes. Recovery codes are a backup: ten random strings printed once at enrolment time. Each can be used once to sign in without a TOTP code.

Store recovery codes as SHA-256 digests, not in plain text. When a user presents one, hash it and compare. If it matches, delete that digest so it cannot be used again. One row per code is enough: belongs_to :user and a digest column with a unique index, so a code can be found by its hash and destroyed once it is spent.

The example above shows a RecoveryCode struct that stores a digest and marks a code as used by deleting it.

Tip

Generate recovery codes with SecureRandom.alphanumeric(12) or similar. Show them once, let the user copy or print them, and never display them again.

Passkeys replace the password with a key pair on the device

passkey: A public-key credential stored on a device (phone, laptop or hardware key) that proves identity by signing a challenge; the private key never leaves the device. replace passwords with a key pair: the private key stays on the device (a phone, a laptop or a hardware key), and the public key goes to the server. Signing in proves the user holds the private key without ever sending it.

The protocol behind passkeys is WebAuthn: The browser API that lets a site register and authenticate passkeys; it talks to the device's authenticator and returns a signed assertion the server can verify.. A Rails app acts as the In WebAuthn, the relying party is the server that issues challenges, verifies responses and stores credentials.: it sends challenges and verifies responses. The browser handles the cryptography and talks to the authenticator.

Registration hands the server a public key

Registration creates a credential. The server sends a challenge and user information; the browser asks the authenticator to create a key pair; the authenticator returns a signed attestation that includes the public key. The server stores the public key and the credential ID.

Interactive · message by message Passkey registration ceremony Follow the exchange between browser, authenticator and server
Passkey registration ceremony, step 9 of 9. Messages so far: Browser to Server: GET /passkeys/new; Server to Browser: publicKey options; Browser to Authenticator: create credential; Authenticator to Browser: attestation; Browser to Server: POST /passkeys; Server to Browser: 201 Created.BrowserAuthenticatorkey pair createdServerpublic key storedGET /passkeys/newpublicKey optionscreate credentialattestationPOST /passkeys201 Created

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. Request registration. The user taps Register a passkey. The browser fetches registration options from the server.
  2. Build challenge. The server generates a random challenge and user information. It returns them as JSON: rp.id, user.id, challenge and pubKeyCredParams.
  3. Send options. The server sends the registration options to the browser.
  4. Call WebAuthn API. The browser calls navigator.credentials.create(options). The WebAuthn API prompts the authenticator to create a new key pair.
  5. Create key pair. The authenticator generates a key pair. The private key stays on the device. It signs an attestation that includes the public key.
  6. Return attestation. The authenticator returns the signed attestation and the credential ID to the browser.
  7. Send to server. The browser sends the attestation response to the server.
  8. Verify and store. The server verifies the attestation signature and the challenge. It stores the public key and credential ID in the database, bound to the user.
  9. Confirm. The server responds with success. The user can now sign in with this passkey.

The JSON shapes the server and browser exchange:

WebAuthn challenge shapes (illustrative) Ruby · webauthn_shapes.rb
# WebAuthn challenge shapes (illustrative, no actual WebAuthn ceremony)
#
# This script prints the JSON shapes a Rails server and browser exchange
# during passkey registration and authentication. It uses plain hashes,
# not the webauthn-ruby gem, because that gem is not installed in the
# pinned toolchain. The shapes follow the WebAuthn specification.
require "json"
require "base64"

# A server draws the challenge and the user handle from SecureRandom, so they differ on every request. This page
# needs one fixed output, so both come from a seeded generator here. A real challenge is unpredictable or it does
# not prevent replay.
BYTES = Random.new(20_261_019)

# --- Registration (credential creation) challenge ---
# The server sends this to navigator.credentials.create()

def base64url(bytes)
  Base64.urlsafe_encode64(bytes, padding: false)
end

user_id = base64url(BYTES.bytes(16))
challenge = base64url(BYTES.bytes(32))

registration_options = {
  publicKey: {
    rp: {
      id: "kaksha.example",
      name: "Kaksha"
    },
    user: {
      id: user_id,
      name: "teacher@kaksha.example",
      displayName: "Asha Sharma"
    },
    challenge: challenge,
    pubKeyCredParams: [
      { type: "public-key", alg: -7 },   # ES256 (ECDSA with P-256)
      { type: "public-key", alg: -257 }  # RS256 (RSASSA-PKCS1-v1_5)
    ],
    timeout: 60000,
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred"
    },
    attestation: "none"
  }
}

puts "=== Registration challenge (server to browser) ==="
puts JSON.pretty_generate(registration_options)

# --- Authentication challenge ---
# The server sends this to navigator.credentials.get()
# allowCredentials lists the credential IDs the user has registered

credential_id_1 = base64url(BYTES.bytes(32))
auth_challenge = base64url(BYTES.bytes(32))

authentication_options = {
  publicKey: {
    rpId: "kaksha.example",
    challenge: auth_challenge,
    timeout: 60000,
    userVerification: "preferred",
    allowCredentials: [
      {
        type: "public-key",
        id: credential_id_1,
        transports: ["internal", "hybrid"]
      }
    ]
  }
}

puts "\n=== Authentication challenge (server to browser) ==="
puts JSON.pretty_generate(authentication_options)

# --- Key fields explained ---
puts "\n=== Key fields ==="
puts "rp.id: The relying-party ID (domain). The authenticator binds the credential to it."
puts "user.id: A server-chosen identifier for the user (not the email)."
puts "challenge: A random value the authenticator signs; prevents replay."
puts "pubKeyCredParams: Which algorithms the server accepts (ES256 and RS256 here)."
puts "allowCredentials: During authentication, the credential IDs the user has registered."

Output

=== Registration challenge (server to browser) ===
{
  "publicKey": {
    "rp": {
      "id": "kaksha.example",
      "name": "Kaksha"
    },
    "user": {
      "id": "Z0aAtdB2RULoUJH3E-t-xQ",
      "name": "teacher@kaksha.example",
      "displayName": "Asha Sharma"
    },
    "challenge": "becGLSu7Mw7d_8peoP70BGiAAljSyW6MkELOvC6ibxM",
    "pubKeyCredParams": [
      {
        "type": "public-key",
        "alg": -7
      },
      {
        "type": "public-key",
        "alg": -257
      }
    ],
    "timeout": 60000,
    "authenticatorSelection": {
      "residentKey": "preferred",
      "userVerification": "preferred"
    },
    "attestation": "none"
  }
}

=== Authentication challenge (server to browser) ===
{
  "publicKey": {
    "rpId": "kaksha.example",
    "challenge": "ePiCbT-E8S4LHBCbx3YTZyZdA8B9i4y2LWSOJk9iU9o",
    "timeout": 60000,
    "userVerification": "preferred",
    "allowCredentials": [
      {
        "type": "public-key",
        "id": "oQRBWSZ77cfG7MSJ-xvMuXI-GPJqbYv40vRzUjYsDO4",
        "transports": [
          "internal",
          "hybrid"
        ]
      }
    ]
  }
}

=== Key fields ===
rp.id: The relying-party ID (domain). The authenticator binds the credential to it.
user.id: A server-chosen identifier for the user (not the email).
challenge: A random value the authenticator signs; prevents replay.
pubKeyCredParams: Which algorithms the server accepts (ES256 and RS256 here).
allowCredentials: During authentication, the credential IDs the user has registered.

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby webauthn_shapes.rb

Sign-in proves the device still holds the private key

When the user signs in, the server sends a new challenge and a list of credential IDs the user has registered. The browser asks the authenticator to sign the challenge with the matching private key. The server verifies the signature with the stored public key.

Key idea

Passkeys are phishing-resistant: the authenticator checks that the origin of the page matches the relying-party ID before signing, so a spoofed page cannot obtain a valid signature.

Checkpoint

Check: Passkeys and WebAuthn

  1. Question 1 of 2 Where is the private key of a passkey stored?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: On the user's device (phone, laptop or hardware key)

    Passkeys keep the private key on the authenticator device. The server stores only the public key.

  2. Question 2 of 2 Why can a spoofed page not obtain a valid passkey signature?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The authenticator checks that the page's origin matches the relying-party ID before signing

    The authenticator binds the credential to the relying-party ID. A spoofed page at a different origin cannot obtain a signature.

The relying-party ID ties a credential to one domain

The relying-party ID: The domain (without port) a passkey credential is bound to; a page on any other domain gets no signature from that credential, which stops phishing. is usually the domain: kaksha.example for an app at https://kaksha.example. The authenticator binds the credential to this ID, so a credential created for kaksha.example cannot be used on attacker.example.

The origin the browser sends must match the ID. The spec allows the relying-party ID to be a registrable domain suffix of the origin’s effective domain, so a credential created with the ID kaksha.example works on both kaksha.example and staging.kaksha.example. Set the ID to the registrable parent domain (no leading dot), or register a separate credential on staging.

Warning

Keep a recovery route. If a user loses their passkey device, they need another way to regain access: TOTP, recovery codes or email verification.

OAuth sign-in starts with a POST so login CSRF cannot forge it

OAuth and OpenID Connect let users sign in with a provider such as Google, GitHub or Microsoft. The user does not create a password on your site; your app trusts the provider to authenticate them.

The OmniAuth gem handles the OAuth flow. In OmniAuth 2.x, the provider login starts with a POST to /auth/:provider, not a GET. This change prevents login CSRF: an attacker who tricks a user into clicking a link to /auth/github cannot bind the attacker’s GitHub account to the victim’s session, because a GET does not start the flow.

The omniauth-rails_csrf_protection gem (2.0.1) adds one more check: it requires Rails’ authenticity token on that POST. Without this gem, an attacker could still forge a POST from another site (browsers send cookies by default). With it, the POST must carry the token from the Rails form or the CSRF meta tag:

OmniAuth with CSRF protection in config/initializers/ Ruby · omniauth_config_snippet.rb
# config/initializers/omniauth.rb
#
# OmniAuth 2.x with omniauth-rails_csrf_protection.
# The provider login (POST /auth/:provider) checks Rails' authenticity token.
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :github,
           Rails.application.credentials.github[:client_id],
           Rails.application.credentials.github[:client_secret],
           scope: "user:email"

  provider :google_oauth2,
           Rails.application.credentials.google[:client_id],
           Rails.application.credentials.google[:client_secret],
           scope: "email,profile"
end

# omniauth-rails_csrf_protection checks the authenticity token on POST.
# GET requests to /auth/:provider return 404 (OmniAuth 2.x default).
OmniAuth.config.allowed_request_methods = [:post]
Interactive · message by message One provider sign-in, step by step Follow one provider sign-in, from the form POST to the linked account
OmniAuth sign-in, POST first, step 8 of 8. Messages so far: Browser to Rails app: POST /auth/github; Rails app to Provider: 302 authorize; Provider to Browser: 302 code + state; Browser to Rails app: GET callback; Rails app to Provider: code for token.BrowserRails appsession startedProviderconsent givenPOST /auth/github302 authorize302 code + stateGET callbackcode for token

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Submit the sign-in form. The sign-in page renders a button_to, not a link: tapping it posts a form that carries the Rails authenticity token.
  2. Check the Rails token. omniauth-rails_csrf_protection verifies the authenticity token on that POST. A forged request from another site has no valid token, so the flow stops here.
  3. Redirect to the provider. OmniAuth builds the authorization URL with the client id, the redirect URI, the scopes and a state value, and redirects the browser to it.
  4. The user signs in there. The user authenticates with the provider and agrees to share the profile the scopes ask for. Your app never sees that password.
  5. The provider calls back. The provider redirects the browser back to your callback URL with a short-lived code and the same state value.
  6. Browser hits the callback. The browser follows the redirect to your app. OmniAuth checks that the state matches the one it issued, which ties the reply to the request it started.
  7. Exchange code for a token. Server to server, your app swaps the code for an access token and reads the profile: the provider id, the email address and whether the provider verified it.
  8. Link by verified email. The app links the provider identity to an existing user only when both sides have verified the same address, then starts a session.

The OWASP CSRF Prevention Cheat Sheet recommends token verification on login forms. This gem applies that recommendation to OmniAuth’s provider POST.

When a user signs in with a provider for the first time, your app must decide whether to create a new account or link to an existing one. The safest rule: link only when the provider reports a verified email that matches an existing account’s verified email. Never link by unverified email; the attacker could register with any address at a permissive provider.

Checkpoint

Check: OAuth sign-in and login CSRF

  1. Question 1 of 2 Why does OmniAuth 2.x start provider login with POST, not GET?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A GET link could be forged to bind an attacker's provider account to the victim's session

    A GET link to /auth/:provider could be forged. POST requires a form, and omniauth-rails_csrf_protection adds the Rails token check.

  2. Question 2 of 2 When a user signs in with a provider for the first time, how should the app decide whether to link to an existing account?
    Show the answer: Question 2 of 2

    Answer: Link only when the provider reports a verified email that matches an existing verified email

    Link by verified email only. An attacker could register with the victim's unverified email at a permissive provider.

The generator, Devise and Rodauth each fit a different app

The Rails 8 authentication generator gives you code you own: a User model, a Session model, a Current object and controllers for sign-in, sign-out and password reset. You add sign-up, email verification, MFA and social login yourself.

Devise (5.0.4) bundles modules for confirmable, lockable, recoverable, rememberable, trackable and more. It generates views and controllers and handles many edge cases.

Rodauth (via rodauth-rails 2.2.3) is a security-first library with a different philosophy. It stores sessions in the database by default, supports MFA and WebAuthn out of the box, and enforces strict defaults.

Interactive · answer and compare Choosing an authentication approach Answer questions about your app and see which approach fits
Does your app need MFA or passkeys soon?
Do you want to own every line of auth code?
How large is your team?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

The generator, Devise and Rodauth against four criteria
Approach Full control Built-in modules MFA out of the box Ecosystem size
Rails 8 generator What it gives you: code you own Good you own the code: every line is in your app Poor no modules: you add sign-up and MFA yourself Poor no MFA: add TOTP or passkeys with gems or your own code Mixed new in Rails 8: fewer tutorials than Devise
Devise What it gives you: modules and views Mixed generators and modules: less code to read Good confirmable, lockable, recoverable built in Mixed no built-in MFA: add devise-two-factor or similar Good large ecosystem: many guides and answers
Rodauth What it gives you: security-first defaults Mixed configuration DSL: less code, more rules Good features for most auth needs Good TOTP, WebAuthn and recovery codes built in Mixed smaller than Devise but growing
How the answers decide
  • “Does your app need MFA or passkeys soon?” Yes, MFA is a requirement: adds a caveat to Rails 8 generator and Devise. MFA is not built in: you will add a gem or write the code yourself
  • “Do you want to own every line of auth code?” Yes, I want full control: adds a caveat to Devise and Rodauth. generators and DSLs mean less code in your repo
  • “How large is your team?” One or two developers and “Does your app need MFA or passkeys soon?” Yes, MFA is a requirement: adds a caveat to Rails 8 generator. adding MFA yourself takes time a small team may not have

Key idea

Pick the generator when you want full control and a small footprint. Pick Devise when you need its modules and want a well-trodden path. Pick Rodauth when security defaults and built-in MFA matter more than ecosystem size.

Practise

Practice that adapts to you

Practise this lesson

8 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 TOTP codes change every how many seconds, by default?

    Type a number.

    Show the answer: Question 1 of 8

    Answer: 30 seconds

    RFC 6238 defines a 30-second step by default. Each step produces a new code.

  2. Question 2 of 8 Recovery codes should be stored as what?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: SHA-256 digests, single-use

    Store recovery codes as hashes. Delete the digest after one use.

  3. Question 3 of 8 What does the relying-party ID bind a passkey credential to?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: The domain (without port) the server runs on

    The relying-party ID is usually the domain. A credential for kaksha.example cannot be used on attacker.example.

  4. Question 4 of 8 Which of these does the Rails 8 authentication generator give you?

    Choose every answer that is right.

    Show the answer: Question 4 of 8

    Answer:

    • A Session model and a Current object
    • Sign-in, sign-out and password-reset controllers

    The generator covers password sign-in, sign-out and password reset with code you own. Sign-up, email verification, MFA and social login are yours to add.

  5. Question 5 of 8 What does the server store when a user registers a passkey?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: The public key and the credential ID

    The authenticator keeps the private key and sends back a signed attestation holding the public key. The server stores that key with the credential ID and verifies signatures against it later.

  6. Question 6 of 8 How does provider sign-in start in OmniAuth 2.x?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: With a POST to /auth/:provider from a form that carries the Rails authenticity token

    The sign-in control is a form button, not a link: the POST carries the authenticity token, so the flow can only start from a page your app rendered for that session.

  7. Question 7 of 8 A provider returns an email that matches an existing user, but reports it as unverified. What should the app do?
    Show the answer: Question 7 of 8

    Answer: Treat it as a new identity and ask the user to prove the address before linking

    Link a provider identity to an existing user only when both sides have verified the same address. Until then, the safe path is a new identity plus your own verification step.

  8. Question 8 of 8 For a small team that needs MFA soon, which approach fits best?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: Rodauth, because it has TOTP and WebAuthn built in

    Rodauth ships TOTP, WebAuthn and recovery codes. A small team with an MFA requirement saves time.

Interview questions

Warm-up (fresher to 2–5 years): What is a passkey, in one sentence, and how is it different from a password?

A passkey is a public-key credential stored on a device (phone, laptop or hardware key) that proves identity by signing a challenge with a private key the server never sees. A password is a shared secret: both the user and the server (as a hash) hold the same value, and anyone who learns it can use it. A passkey cannot be phished because the authenticator checks the origin before signing; a password can be entered on a spoofed page.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    We lose TOTP seeds in a backup. An attacker who reads the backup can now do what, and how do you stop it?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    OmniAuth 2.x starts provider login with POST. Why, and what does omniauth-rails_csrf_protection add?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How is a TOTP code computed?

    The user's authenticator app and the server each hold a copy of one secret and combine it with the current time, so a new six-digit code appears every 30 seconds.

  2. How should an app keep a TOTP secret and its recovery codes?

    Store the TOTP secret encrypted at rest and never log it, since anyone holding it can generate valid codes. Hash the recovery codes and let each one work only once.

  3. How does a passkey sign a user in without any shared secret?

    The device keeps the private key and the server stores only the public key. The device signs the server's challenge, which proves it holds the key without ever sending it.

  4. Why can a spoofed page not obtain a valid passkey signature?

    The credential is bound to its relying-party ID, and nothing is signed for a page whose origin does not match that ID.

  5. How does a Rails app stop login CSRF when a user starts a social sign-in?

    OmniAuth 2.x starts the provider login with a POST, and omniauth-rails_csrf_protection checks the Rails authenticity token on that POST.

  6. When may a provider sign-in be linked to an existing user account?

    Only when both the provider and your own app have verified that same email address.

  7. How do the generator, Devise and Rodauth differ as choices?

    The generator gives full control and a small footprint, Devise brings its modules and a well-trodden path, and Rodauth puts security defaults and built-in MFA ahead of ecosystem size.

Key takeaways

  • TOTP adds a second factor by computing a code from a shared secret and the current time; store that secret encrypted and provide recovery codes hashed and single-use.
  • Passkeys replace passwords with a key pair: the private key stays on the device, the server stores the public key, and signing proves possession without revealing the key.
  • The passkey registration and authentication ceremonies exchange challenges and signed responses; the authenticator binds credentials to the relying-party ID, which stops phishing.
  • OmniAuth 2.x starts provider login with POST, and omniauth-rails_csrf_protection checks the Rails authenticity token on that POST, which together prevent login CSRF.
  • Link a provider account to an existing user only when both the provider and your app have verified the same email address.
  • The generator, Devise and Rodauth fit different projects: the generator for full control, Devise for its modules, Rodauth for security-first defaults.

Terms in this lesson

Active Record Encryption
Rails' built-in column encryption with encrypts: the ciphertext is stored in the database and decrypted with keys from credentials, so a database leak does not expose the plain values.
login CSRF
An attack where a forged request starts the OAuth flow with the attacker's provider identity, binding it to the victim's session so the attacker can access anything the victim does.
passkey
A public-key credential stored on a device (phone, laptop or hardware key) that proves identity by signing a challenge; the private key never leaves the device.
relying-party ID
The domain (without port) a passkey credential is bound to; a page on any other domain gets no signature from that credential, which stops phishing.
TOTP
Time-based One-Time Password: a six-digit code derived from a shared secret and the current 30-second Unix time step, used as a second factor.
WebAuthn
The browser API that lets a site register and authenticate passkeys; it talks to the device's authenticator and returns a signed assertion the server can verify.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.