Pro Passkeys
Viewing latest docs.
Switch version: v3

Passkeys

Passkeys are a phishing-resistant alternative to passwords. Instead of typing a password, users authenticate with whatever their device already uses - Face ID, Touch ID, Windows Hello, or a hardware security key like a YubiKey. There’s no password to steal, no password to forget, and no phishing page that can intercept credentials.

Petal Pro ships with passkey support out of the box. It’s disabled by default so you opt in when you’re ready.

Enabling passkeys

Flip the feature flag in your config:

config/config.exs
config :petal_pro, :passkeys_enabled, true

That’s it. The sign-in page will now show a “Sign in with passkey” button, and the security settings page will show a passkey management section where users can register their devices.

To check the flag at runtime:

elixir
PetalPro.passkeys_enabled?()

Custom domain config

By default, passkeys are scoped to the host of your endpoint URL. This is the rp_id (relying party ID) in WebAuthn terms - it determines which domains a passkey is valid for.

Most of the time you don’t need to touch this. If you’re running on a subdomain and want passkeys to work across the whole domain (e.g., register on app.example.com and sign in on example.com), set these explicitly:

config/runtime.exs
config :petal_pro,
  passkeys_rp_id: "example.com",
  passkeys_origin: "https://app.example.com"

passkeys_rp_id is the domain passkeys are scoped to. passkeys_origin is the full origin your app is served from. In production, set these via environment variables if your domain isn’t hardcoded.

⚠️ Warning: A passkey registered on one origin won't work on another. If you change passkeys_rp_id in production, existing passkeys will stop working. Get this right before users start registering.

How it works for users

Registering a passkey

Users manage their passkeys from the security settings page at /app/users/security. They can:

  • Register up to 5 passkeys
  • Give each passkey a name (e.g. “MacBook Touch ID”, “iPhone Face ID”)
  • Delete passkeys they no longer need

When registering, the browser prompts for the local authenticator - Face ID, fingerprint, PIN, or a hardware key. The private key never leaves the device. Petal Pro stores a public key credential in the user_passkeys table.

Passkey registration form

Once registered, the security settings page shows all your passkeys with the option to toggle 2FA mode or remove individual keys.

Security settings with passkeys registered

Signing in

When passkeys are enabled, the sign-in page shows a “Sign in with passkey” button. The user clicks it, the browser presents available passkeys for the site, and they authenticate with their device. No password needed.

Sign in page with passkey button

The relevant routes:

GET  /users/passkey-verify   # shows the passkey challenge prompt
POST /users/passkey-verify   # completes the authentication

Primary auth vs 2FA mode

Passkeys work in two modes in Petal Pro:

Primary auth (passwordless) - The user clicks “Sign in with passkey” on the login page. They never touch a password field. This works for any user who has registered at least one passkey.

Second factor (2FA) - After signing in with a password, users with passkey_2fa_preferred: true get redirected to /users/passkey-verify to confirm with their passkey instead of a TOTP code. This gives you the convenience of passkeys without fully replacing password login.

The passkey_2fa_preferred flag lives on the User schema. Users can toggle it from their security settings once they have a passkey registered. You can check it programmatically:

elixir
PetalPro.Accounts.passkey_2fa_preferred?(user)

ℹ️ Note: Passkeys and TOTP are independent. A user can have both set up. If passkey_2fa_preferred is true, the passkey prompt takes precedence over the TOTP prompt after password sign-in.

Removing passkeys entirely

If you don’t want passkeys in your app at all, here’s what to clean up:

  1. Config - Remove or leave passkeys_enabled: false in config/config.exs. This is enough to hide all the UI.

  2. Schema - If you want to remove the database fields too, drop user_passkeys table and remove passkey_2fa_preferred from the users table. You’ll need to write a migration for that.

  3. Routes - Remove the passkey routes from router.ex. Search for /users/passkey-verify and the EditPasskeysLive route.

  4. UI - The “Sign in with passkey” button on the login page and the passkey section in security settings are both gated on passkeys_enabled?(), so they disappear automatically when the flag is off. If you want to remove the dead code entirely, search for passkeys_enabled? and EditPasskeysLive across the codebase.

  5. Dependencies - Passkeys are powered by the wax Hex package. If you’re stripping the feature entirely, remove it from mix.exs.

Most projects won’t need to do any of this - disabling the flag is enough to ship without passkeys, and you can always turn it back on later.