navree
Documentation

Passwordless sign-in

Magic links, emailed sign-in codes and passkeys (WebAuthn with ES256 and Ed25519), next to or instead of passwords.

On this page

Passwords get reused, phished and forgotten. navree-auth offers three ways to sign in without one: a magic link mailed to the address, a short code mailed to the address, and passkeys, the WebAuthn credentials that browsers and phones keep in their secure hardware. Each is off by default and turned on in configuration; all three end in the same token pair as a password sign-in. This page assumes AuthModule is set up as described in Authentication.

A magic link signs the user in by proving they can read mail at the address.

config/app.toml
  1. Request a link

    HTTP
    JSON

    The answer is always 202 Accepted with this message, registered address or not. For a registered one, the mail links to {auth.magic_link.url}?token=.... Without a url, the mail carries the token itself, for API and mobile clients. A second request within throttle_secs is accepted and sends nothing.

  2. Sign in with the token

    The page at url reads token from its query string and posts it:

    HTTP

    The answer is the same as a password sign-in: the user and a token pair, or a two-factor challenge when the account uses two-factor authentication.

  • A token is <id>.<48 random characters>, stored as its SHA-256 digest in login_tokens. It works once and expires after ttl_secs.
  • A new link replaces the user's previous one.
  • Following a link proves the address: an unverified address becomes verified.
  • An unknown, used, expired or wrong token gets 401 Invalid or expired sign-in code.

Emailed codes

A code works like a magic link the user types instead of clicks: handy when the mail is read on another device.

config/app.toml

POST /api/auth/otp with {"email"} mails a code and answers 202 the same way as a magic link request. The user types the code into your app, which sends it with the address:

HTTP

Spaces in the code are ignored. A correct code signs the user in like a password (or answers a two-factor challenge). Six digits are few, so codes are guarded three ways:

  • Per code. Each wrong guess counts against the code; after max_attempts (5) it is burned and the user asks for a new one.
  • Per address and client. Every attempt counts towards the login throttle of the address and the client's IP address, before the code is checked. Too many get 429 Too Many Requests.
  • At rest. Codes are stored as an HMAC-SHA-256 keyed by a key derived from the application key, so a copy of the database doesn't reveal them.

Wrong codes, unknown addresses and expired or used codes all get 401 Invalid or expired sign-in code. Codes are generated with the operating system's random number generator, and each digit is equally likely.

Both request routes allow 10 requests per client per minute, on top of the per-address throttle, so nobody can flood many inboxes from one address.

The mails

Both mails go through the app's Mailer once the request commits, like the account mails: with MailModule in the app, and queued when a job queue is there. Without MailModule nothing is sent and a warning is logged. To use your own templates, set auth.magic_link.mail_template or auth.otp.mail_template to a navree-view template pair ({template}.html and .txt):

MailSubjectTemplate context
Magic linkYour sign-in linkname, email, token, url (empty without auth.magic_link.url), expires_in_minutes
Sign-in codeYour sign-in codename, email, code, expires_in_minutes

Passkeys

A passkey is a key pair made by the user's device: the private key never leaves it, and signing in is a fingerprint, a face or a PIN. Passkeys can't be phished, because the browser only uses them on your site's domain. navree-auth verifies them itself with ring: ES256 (P-256) and EdDSA (Ed25519) credentials, which every current authenticator offers.

config/app.toml

rp_id and origins are required when passkeys are enabled; origins have no trailing slash. A passkey registered for example.com works on app.example.com and any other subdomain you list in origins.

Registering a passkey

A signed-in user registers a passkey in two requests. The front end passes the options to the browser's WebAuthn API and posts back what it returns:

web/src/passkeys.ts

The options carry a challenge, the relying party, the user, the two algorithms, attestation: "none" and the user's existing passkeys as excludeCredentials. POST /api/auth/passkeys/register answers 201 Created with the passkey:

JSON

Before it stores the credential, Navree checks that the client data is a webauthn.create for a challenge it issued (each works once, within timeout_secs) from one of origins, that the authenticator data names rp_id, that the user was present (and verified, when user_verification = "required"), and that the key is ES256 or Ed25519. No attestation is requested, so attestation statements aren't checked. A refused registration gets 401 Invalid passkey; the reason goes to the log.

Both registration routes refuse impersonation sessions. GET /api/auth/passkeys lists the user's passkeys and DELETE /api/auth/passkeys/{id} removes one (404 for someone else's).

Signing in with a passkey

web/src/passkeys.ts

email is optional. With it, the options list that account's passkeys in allowCredentials. Without it the list is empty and the browser offers the passkeys it holds for your site (discoverable credentials), so the user doesn't type anything.

POST /api/auth/passkeys/login checks the assertion like a registration, plus the signature over the authenticator data and the hash of the client data, the user handle when the browser sends one, and the signature counter: a counter that doesn't grow is refused, because it suggests a cloned authenticator. A valid assertion answers like a password sign-in:

  • A passkey that verified the user (fingerprint, face, PIN) is two factors in one: the session starts at once.
  • A passkey that only checked presence counts as one factor: a user with two-factor authentication gets the usual challenge.

Anything wrong gets 401 Invalid passkey, recorded as auth.login_failed with metadata.method = "passkey" and the reason.

The passkeys table

Column
id, user_idThe passkey and its owner (rows go with the user)
credential_idThe credential id, base64url
credential_hashSHA-256 of the raw credential id; unique, the lookup key
public_key, algorithmThe public key (base64url) and its COSE algorithm: -7 (ES256) or -8 (EdDSA). Never serialized
sign_countThe authenticator's counter at the last sign-in
transports, nameWhat the browser reported, and the user's name for it
created_at, last_used_at

Testing

Mail-based flows are tested like the account flows: use the array mail transport and read the token or code from the mail.

For passkeys, navree::auth::testing::VirtualAuthenticator plays the browser and the authenticator. It answers the options of the passkey routes with real ES256 (or Ed25519) signatures:

Rust

VirtualAuthenticator::new(rp_id, origin).ed25519() creates Ed25519 keys, and .without_user_verification() an authenticator that only checks presence. Change the JSON it returns to test refusals: another origin, a tampered signature, a replayed assertion.

In code

AuthService methodWhat it does
send_magic_link(email, &meta)Mails a magic link to a registered address
login_with_magic_link(token, &meta)A LoginOutcome for a valid token
send_login_code(email, &meta)Mails a sign-in code to a registered address
login_with_code(email, code, &meta)A LoginOutcome for a valid code
passkey_registration_options(&user)The publicKey creation options (JSON)
register_passkey(&user, name, &credential, &meta)Stores the passkey of a valid registration
passkeys(user_id), delete_passkey(user_id, id, &meta)List and remove passkeys
passkey_login_options(email)The publicKey request options (JSON)
login_with_passkey(&credential, &meta)A LoginOutcome for a valid assertion

Audit entries

ActionKindWhen
auth.magic_link_sent, auth.otp_sentauthA link or code was mailed (after the commit)
auth.loginauthA sign-in succeeded; metadata.method is magic_link, otp or passkey
auth.login_failedsecurityA wrong token, code or passkey; metadata.method, and metadata.reason for passkeys
auth.passkey_registered, auth.passkey_deletedsecurityA passkey was added or removed; metadata.passkey_id

Configuration

KeyDefault
auth.magic_link.enabledfalseMounts POST /auth/magic-link and /magic-link/verify
auth.magic_link.ttl_secs900How long a link works
auth.magic_link.throttle_secs60Shortest time between two links to one address
auth.magic_link.urlunsetThe front-end page; the mail carries the token alone without it
auth.magic_link.mail_templateunsetA navree-view template pair
auth.otp.enabledfalseMounts POST /auth/otp and /otp/verify
auth.otp.length6Digits per code, 4 to 10
auth.otp.ttl_secs600How long a code works
auth.otp.max_attempts5Wrong guesses before a code is burned
auth.otp.throttle_secs60Shortest time between two codes to one address
auth.otp.mail_templateunsetA navree-view template pair
auth.passkeys.enabledfalseMounts /auth/passkeys/*
auth.passkeys.rp_idunsetThe relying party id; required when enabled
auth.passkeys.rp_name"Navree"Shown by the browser
auth.passkeys.origins[]Allowed origins; at least one when enabled
auth.passkeys.timeout_secs300How long a ceremony may take
auth.passkeys.user_verification"preferred"required, preferred or discouraged

Limits

  • Mail is the weak link. Magic links and codes are as safe as the user's mailbox. Keep ttl_secs short, and keep two-factor authentication for accounts that matter: it still applies after a link or a code.
  • No attestation. Passkeys are accepted from any authenticator; there is no allow list of authenticator models.
  • ES256 and Ed25519 only. Authenticators that offer only RS256 can't register.
  • Counters per passkey, not per device. Synced passkeys (iCloud Keychain, Google Password Manager) report a counter of 0, which is accepted; the clone check then doesn't apply.