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.
Magic links
A magic link signs the user in by proving they can read mail at the address.
[auth.magic_link]
enabled = true
url = "https://app.example.com/magic-link" # the page the mail links to
ttl_secs = 900 # links work for 15 minutes
throttle_secs = 60 # at most one mail per address a minuteRequest a link
HTTP POST /api/auth/magic-link HTTP/1.1 Content-Type: application/json {"email": "[email protected]"}JSON {"message": "If the email address is registered, a sign-in message has been sent."}The answer is always
202 Acceptedwith this message, registered address or not. For a registered one, the mail links to{auth.magic_link.url}?token=.... Without aurl, the mail carries the token itself, for API and mobile clients. A second request withinthrottle_secsis accepted and sends nothing.Sign in with the token
The page at
urlreadstokenfrom its query string and posts it:HTTP POST /api/auth/magic-link/verify HTTP/1.1 Content-Type: application/json {"token": "01a17b6e-2d40-7bb2-8c1d-5f00b7f8a2c4.T0mOzsQ4mK8T9u2zJwH4pV1rX7bN3eL6yD0fA5gS8hQ1kC9w"}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 inlogin_tokens. It works once and expires afterttl_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.
[auth.otp]
enabled = true
length = 6 # digits (4 to 10)
ttl_secs = 600 # codes work for 10 minutes
max_attempts = 5 # wrong guesses a code survives
throttle_secs = 60 # at most one mail per address a minutePOST /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:
POST /api/auth/otp/verify HTTP/1.1
Content-Type: application/json
{"email": "[email protected]", "code": "481 207"}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):
| Subject | Template context | |
|---|---|---|
| Magic link | Your sign-in link | name, email, token, url (empty without auth.magic_link.url), expires_in_minutes |
| Sign-in code | Your sign-in code | name, 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.
[auth.passkeys]
enabled = true
rp_id = "example.com" # your registrable domain
rp_name = "Example" # shown by the browser
origins = ["https://app.example.com"] # where your front end runs
timeout_secs = 300
user_verification = "preferred" # required, preferred or discouragedrp_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:
const { publicKey } = await api.post("/api/auth/passkeys/register/options");
const credential = await navigator.credentials.create({
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(publicKey),
});
await api.post("/api/auth/passkeys/register", {
name: "Work laptop",
credential: credential.toJSON(),
});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:
{
"algorithm": "ES256",
"created_at": "2026-10-27T09:30:12.512Z",
"id": "01a17b70-1c22-7d1e-b3b4-2b8de2f6c911",
"last_used_at": null,
"name": "Work laptop",
"transports": ["internal", "hybrid"]
}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
const { publicKey } = await api.post("/api/auth/passkeys/login/options", { email });
const assertion = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(publicKey),
});
const session = await api.post("/api/auth/passkeys/login", {
credential: assertion.toJSON(),
});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_id | The passkey and its owner (rows go with the user) |
credential_id | The credential id, base64url |
credential_hash | SHA-256 of the raw credential id; unique, the lookup key |
public_key, algorithm | The public key (base64url) and its COSE algorithm: -7 (ES256) or -8 (EdDSA). Never serialized |
sign_count | The authenticator's counter at the last sign-in |
transports, name | What 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:
use navree::auth::testing::VirtualAuthenticator;
use serde_json::json;
let authenticator = VirtualAuthenticator::new("example.com", "https://app.example.com");
let client = app.client();
let options = client
.post("/api/auth/passkeys/register/options")
.bearer(&access)
.send()
.await
.assert_status(200)
.json_value();
let credential = authenticator.create(&options["publicKey"]);
client
.post("/api/auth/passkeys/register")
.bearer(&access)
.json(&json!({ "name": "Test key", "credential": credential }))
.send()
.await
.assert_status(201);
let options = client
.post("/api/auth/passkeys/login/options")
.json(&json!({}))
.send()
.await
.json_value();
let assertion = authenticator.get(&options["publicKey"]).expect("a passkey");
client
.post("/api/auth/passkeys/login")
.json(&json!({ "credential": assertion }))
.send()
.await
.assert_status(200);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 method | What 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
| Action | Kind | When |
|---|---|---|
auth.magic_link_sent, auth.otp_sent | auth | A link or code was mailed (after the commit) |
auth.login | auth | A sign-in succeeded; metadata.method is magic_link, otp or passkey |
auth.login_failed | security | A wrong token, code or passkey; metadata.method, and metadata.reason for passkeys |
auth.passkey_registered, auth.passkey_deleted | security | A passkey was added or removed; metadata.passkey_id |
Configuration
| Key | Default | |
|---|---|---|
auth.magic_link.enabled | false | Mounts POST /auth/magic-link and /magic-link/verify |
auth.magic_link.ttl_secs | 900 | How long a link works |
auth.magic_link.throttle_secs | 60 | Shortest time between two links to one address |
auth.magic_link.url | unset | The front-end page; the mail carries the token alone without it |
auth.magic_link.mail_template | unset | A navree-view template pair |
auth.otp.enabled | false | Mounts POST /auth/otp and /otp/verify |
auth.otp.length | 6 | Digits per code, 4 to 10 |
auth.otp.ttl_secs | 600 | How long a code works |
auth.otp.max_attempts | 5 | Wrong guesses before a code is burned |
auth.otp.throttle_secs | 60 | Shortest time between two codes to one address |
auth.otp.mail_template | unset | A navree-view template pair |
auth.passkeys.enabled | false | Mounts /auth/passkeys/* |
auth.passkeys.rp_id | unset | The 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_secs | 300 | How 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_secsshort, 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.