# Passwordless sign-in

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

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](https://navree.com/docs/security/authentication.md).

## Magic links

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

```toml title="config/app.toml"
[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 minute
```

### 1. Request a link

```http
POST /api/auth/magic-link HTTP/1.1
Content-Type: application/json

{"email": "ada@example.com"}
```

```json
{"message": "If the email address is registered, a sign-in message has been sent."}
```

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
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](https://navree.com/docs/security/account-flows.md#signing-in-with-a-second-factor) 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.

```toml title="config/app.toml"
[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 minute
```

`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
POST /api/auth/otp/verify HTTP/1.1
Content-Type: application/json

{"email": "ada@example.com", "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](https://navree.com/docs/security/authentication.md#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](https://navree.com/docs/security/account-flows.md#before-you-start): 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`):

| Mail | 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.

```toml title="config/app.toml"
[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 discouraged
```

`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:

```ts title="web/src/passkeys.ts"
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:

```json
{
  "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](https://navree.com/docs/security/impersonation.md) 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

```ts title="web/src/passkeys.ts"
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](https://navree.com/docs/security/account-flows.md#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](https://navree.com/docs/security/account-flows.md#testing): 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
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_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.

- [Account flows](https://navree.com/docs/security/account-flows.md): Passwords, email verification and TOTP two-factor authentication.
- [Sessions and step-up](https://navree.com/docs/security/sessions.md): The sessions these sign-ins start.
- [Mail](https://navree.com/docs/digging-deeper/mail.md): Transports, queued mail and templates.
