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.
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
What you’ll learn · Before you start
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
On this page
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: 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:
- Generates a random secret and stores it in the user’s session (not yet in the database).
- Shows a QR code of the provisioning URI and asks the user to scan it.
- Asks the user to enter the code the app shows.
- 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.
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Enable TOTP. The user opens account settings and taps Enable two-factor authentication.
- Generate secret. Rails generates a random Base32 secret with ROTP::Base32.random. It keeps that secret in the session, not yet in the database.
- Show QR code. Rails builds a provisioning URI and renders a QR code. The user scans it with their authenticator app.
- App stores secret. The authenticator app decodes the URI and keeps a copy. It starts showing six-digit codes that change every 30 seconds.
- Enter code. The user reads the current code from the app and types it into the form.
- 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.
- 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.
- 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
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.
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- Request registration. The user taps Register a passkey. The browser fetches registration options from the server.
- Build challenge. The server generates a random challenge and user information. It returns them as JSON: rp.id, user.id, challenge and pubKeyCredParams.
- Send options. The server sends the registration options to the browser.
- Call WebAuthn API. The browser calls navigator.credentials.create(options). The WebAuthn API prompts the authenticator to create a new key pair.
- 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.
- Return attestation. The authenticator returns the signed attestation and the credential ID to the browser.
- Send to server. The browser sends the attestation response to the server.
- 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.
- 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, 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
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:
# 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] The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- The provider calls back. The provider redirects the browser back to your callback URL with a short-lived code and the same state value.
- 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.
- 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.
- 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.
Link an account only by a verified email
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
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.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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.
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.
-
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?
-
Mid to senior
OmniAuth 2.x starts provider login with POST. Why, and what does omniauth-rails_csrf_protection add?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
- RFC 6238: TOTP: Time-Based One-Time Password Algorithm (IETF)
- rotp (Ruby One Time Password library) (GitHub)
- webauthn-ruby (WebAuthn server-side library) (GitHub)
- omniauth-rails_csrf_protection (GitHub)
- Devise (flexible authentication for Rails) (GitHub)
- OWASP: Cross-Site Request Forgery Prevention Cheat Sheet (OWASP)
Report a problem with this lesson
Kept only in this browser. Your Learn progress