# Passkeys

Source: https://docs.qiick.io/externa/passkeys

Externa supports **passkeys** (WebAuthn) via official **Laravel Fortify** + **`@laravel/passkeys`**. A passkey is passwordless sign-in and also counts as MFA when the project requires two-factor authentication.

<!-- screenshot omitted -->

> **Related**
> Manage UI: [Account settings](https://docs.qiick.io/externa/account-settings#password-2-fa-and-passkeys-fortify). Re-auth before Security: [Confirm password with a passkey](#confirm-password-with-a-passkey). Project MFA gate: [Project settings — Require 2FA](https://docs.qiick.io/externa/project-settings#require-two-factor-authentication). Pre-ship gates: [Security checklist](https://docs.qiick.io/externa/security-checklist). Fortify config: [Application config](https://docs.qiick.io/externa/configuration#fortify-config-fortify-php). Env: [Environment variables — `APP_URL`](https://docs.qiick.io/externa/environment-variables).

> **HTTP vs passkeys**
> **Externa itself works on HTTP** — login with password, Fortify TOTP 2FA, password confirm (typed password), admin UI, APIs, everything **except** WebAuthn/passkeys. Passkeys need a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) (browser rule, not an Externa or Brave bug). See [Requirements](#requirements).

## What passkeys are in Externa

| Concern          | Behavior                                                                                                                                                                                                                                                  |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Package stack    | `laravel/fortify` feature `Features::passkeys()` + `@laravel/passkeys` (React hooks `usePasskeyRegister` / `usePasskeyVerify`)                                                                                                                            |
| Sign-in          | Passwordless: assert a registered credential on the login page — **no email/password and no TOTP challenge** after a successful assertion                                                                                                                 |
| Password confirm | `/user/confirm-password`: password form always; **Confirm with passkey** only if ≥1 passkey + feature on + secure context (`/passkeys/confirm/*`) — same session `passwordConfirmed()` + intended redirect; see [below](#confirm-password-with-a-passkey) |
| MFA for projects | Satisfies project setting `two_factor_required` the same way confirmed TOTP does (`hasPasskeysEnabled()` → ≥1 passkey)                                                                                                                                    |
| Account password | Still required for password login, password changes, and (by default) opening Security settings (`confirmPassword`) — passkey is an alternate confirm path when enrolled                                                                                  |
| Vendor policy    | Use **only** official Fortify / `@laravel/passkeys` — no forks or local patches of those packages                                                                                                                                                         |

User model: implements `PasskeyUser`, uses `PasskeyAuthenticatable` (alongside `TwoFactorAuthenticatable`). Successful passkey login uses `PasskeyLoginResponse`, which redirects via the same `HomePath` resolver as password login.

## Register a passkey (Security settings)

| Item         | Detail                                                                                              |
| ------------ | --------------------------------------------------------------------------------------------------- |
| Page         | **Settings → Security** (`GET /settings/security`, `security.edit`)                                 |
| UI           | `resources/js/pages/settings/security.tsx` (`data-test="passkeys-section"`)                         |
| Feature flag | `Features::canManagePasskeys()` — section hidden when passkeys are disabled in `config/fortify.php` |

### Steps

1. Sign in (email + password, or an existing passkey).
2. Open **Settings → Security**. With default Fortify options, `password.confirm` runs first — confirm your account password **or use Confirm with passkey** if you already have a passkey ([details](#confirm-password-with-a-passkey)), then the security page loads.
3. In **Passkeys**, enter a **device name** (e.g. “MacBook Touch ID”, “Phone”).
4. Click **Add passkey**. The browser/platform authenticator prompt appears (Touch ID, Windows Hello, security key, etc.).
5. Complete the prompt. The list reloads with the new passkey (`id`, `name`, `created_at`, `last_used_at`).

### List and delete

- Registered passkeys are listed on the same page.
- **Delete** calls the Fortify passkey destroy route (`passkey.destroy`). Users can delete only their own passkeys.
- If the project has `two_factor_required` on and this was the user’s only MFA factor (no confirmed TOTP and no remaining passkeys), `EnsureTwoFactorIsEnabled` will send them back to Security until they enroll again.

> The UI checks platform support (`isSupported` from `@laravel/passkeys/react`). That is false on insecure HTTP origins (e.g. `http://externa-core.test`) and on browsers without WebAuthn — CTAs hide or show an unsupported message. Use HTTPS (or `localhost` / `*.localhost`), plus a current browser with a platform authenticator or hardware key. See [Requirements](#requirements).

## Sign in

### Passkey (passwordless)

1. Open the login page (`resources/js/pages/auth/login.tsx`).
2. If passkeys are enabled and supported, a **Sign in with passkey** button appears (`data-test="passkey-login-button"`). Sign-in is **click-only** (no WebAuthn autofill on mount — avoids hung `isLoading` on some browsers).
3. Click the button. Complete the authenticator prompt.
4. On success, the client follows `PasskeyLoginResponse`’s redirect (JSON `redirect` URL or Inertia visit) — same home as password login.

There is **no** Fortify TOTP challenge after a successful passkey assertion. The credential _is_ the second factor.

### Email + password (+ TOTP when required)

1. Submit email and password as usual.
2. If the user has **confirmed Fortify TOTP** enabled, Fortify’s two-factor challenge runs next (unless this session already satisfied 2FA).
3. If the user has **only passkeys** (no TOTP) and signs in with password, password login does **not** automatically use the passkey — they still need a password (and TOTP if TOTP is enabled). Passkey is an alternate login path, not a replacement for the password field on that form.

| Login method                                  | Password needed? | TOTP challenge?         |
| --------------------------------------------- | ---------------- | ----------------------- |
| Passkey assertion                             | No               | No                      |
| Email + password, TOTP off, may have passkeys | Yes              | No                      |
| Email + password, TOTP confirmed              | Yes              | Yes (Fortify challenge) |

## Confirm password with a passkey

Fortify’s `password.confirm` middleware sends the user to `/user/confirm-password` before **sensitive actions** that opt into recent re-auth — notably **Settings → Security** when `confirmPassword` is on for 2FA and/or passkeys (`SecurityController` + `config/fortify.php`). UI: `resources/js/pages/auth/confirm-password.tsx`; props from `FortifyServiceProvider` (`canManagePasskeys`, `hasPasskeys`).

| Path                     | When available                                                                                                                                                                       | Behavior                                                                                                                                |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| Password form            | Always                                                                                                                                                                               | Primary path — type password → **Confirm password** (`POST /user/confirm-password`). Works on **HTTP and HTTPS**; no WebAuthn required. |
| **Confirm with passkey** | Only when **all** of: passkeys feature on (`canManagePasskeys`), user has ≥1 passkey (`hasPasskeys` / `hasPasskeysEnabled()`), and browser secure context + WebAuthn (`isSupported`) | Outline CTA (`data-test="passkey-confirm-button"`) under an “or confirm with passkey” divider                                           |

Ceremony details (passkey path):

1. **Click-only** — `usePasskeyVerify({ autofill: false })` against `GET /passkeys/confirm/options` + `POST /passkeys/confirm`. No WebAuthn autofill on mount (same hung-`isLoading` lesson as login).
2. Success marks the session password-confirmed (`passwordConfirmed()`) and follows the same intended redirect as typing the password (JSON `redirect`, else `/`).
3. **Abort** (dismiss authenticator) → cancelled message (`UserCancelledError`); other errors surface as-is. The password form stays usable while a ceremony runs and after cancel.

> **HTTP fallback**
> On insecure HTTP (e.g. `http://externa-core.test`), the passkey CTA is hidden — expected. Operators still clear `password.confirm` by typing the account password. Prefer [local HTTPS](#local-https-herd-valet-for-passkey-testing) only when you need to exercise the passkey confirm path.

Account-settings summary: [Password, 2FA & passkeys](https://docs.qiick.io/externa/account-settings#password-2-fa-and-passkeys-fortify).

## Relationship with 2FA / `two_factor_required`

Project setting `two_factor_required` is enforced by `EnsureTwoFactorIsEnabled` on the `web` stack. A user is “MFA complete” when **either**:

- `hasEnabledTwoFactorAuthentication()` (confirmed Fortify TOTP), **or**
- passkeys feature on **and** `hasPasskeysEnabled()` (≥1 registered passkey).

### Decision table

| `two_factor_required`                | User has confirmed TOTP | User has ≥1 passkey | Result                                                                                           |
| ------------------------------------ | ----------------------- | ------------------- | ------------------------------------------------------------------------------------------------ |
| Off                                  | —                       | —                   | No MFA gate. Personal TOTP/passkeys optional.                                                    |
| On                                   | Yes                     | —                   | Allowed through (passkeys optional).                                                             |
| On                                   | No                      | Yes                 | Allowed through (TOTP optional).                                                                 |
| On                                   | Yes                     | Yes                 | Allowed through.                                                                                 |
| On                                   | No                      | No                  | Redirect to `/settings/security`; banner `twoFactorEnforcedForUser` until enrollment.            |
| On, but Fortify 2FA feature disabled | —                       | —                   | Enforcement is a **no-op** (middleware requires `Features::canManageTwoFactorAuthentication()`). |
| On, passkeys feature disabled        | No                      | N/A                 | Only confirmed TOTP satisfies the gate.                                                          |

Allowlisted routes while enforced (so enrollment works): security / password / profile / locale settings, Fortify 2FA + passkey registration/store/destroy, password-confirm, and logout. See [Project settings](https://docs.qiick.io/externa/project-settings#require-two-factor-authentication).

## Requirements

### Secure context (passkeys only)

WebAuthn / `PublicKeyCredential` is available only in a **secure context**:

| Origin                                                               | Passkeys?                                        | Rest of Externa (password, TOTP, CMS, …)? |
| -------------------------------------------------------------------- | ------------------------------------------------ | ----------------------------------------- |
| `https://…` (any host)                                               | Yes                                              | Yes                                       |
| `http://localhost` / `http://*.localhost`                            | Yes                                              | Yes                                       |
| `http://externa-core.test` (or other custom `.test` / LAN HTTP host) | **No** — UI shows unsupported / insecure-context | **Yes** — expected; not a Brave bug       |

Do **not** expect HTTP passkeys on custom Herd/Valet `.test` hosts — browsers treat those as insecure. Use password + TOTP on HTTP, or enable local HTTPS below.

Production: passkeys **require HTTPS**. The app can run on HTTP for non-passkey features, but **HTTPS is recommended** in general (sessions, cookies, proxies).

### Config checklist

| Requirement             | Detail                                                                                                                                                                 |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APP_URL` alignment     | `config/fortify.php` → `passkeys.relying_party_id` = host of `APP_URL`; `allowed_origins` = `[config('app.url')]`. Browse at that exact origin (scheme + host + port). |
| `APP_KEY`               | Used as `user_handle_secret` — keep stable; rotating the key can break existing user handles depending on package behavior.                                            |
| Browser / authenticator | Platform authenticator (Touch ID, Face ID, Windows Hello) or roaming authenticator (security key).                                                                     |
| Feature enabled         | `Features::passkeys(['confirmPassword' => true])` in `config/fortify.php` `features` array.                                                                            |
| Migrations              | `passkeys` table must exist — see [Migration](#migration).                                                                                                             |

```php
// config/fortify.php (excerpt)
'passkeys' => [
    'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
    'allowed_origins' => [config('app.url')],
    'user_handle_secret' => config('app.key'),
    'timeout' => 60000,
],

Features::passkeys([
    'confirmPassword' => true,
]),
```

After changing `APP_URL` on a config-cached host, rebuild config (`php artisan config:cache` / `config:clear`).

> If users hit `https://www.example.com` while `APP_URL` is `https://example.com` (or HTTP vs HTTPS, or a different port), registration and assertion fail. Keep proxy/`APP_URL`/browser URL aligned — same rule as [Deployment](https://docs.qiick.io/externa/deployment).

### Local HTTPS (Herd / Valet) for passkey testing

When you want passkeys on a `.test` host:

1. Secure the site: `herd secure externa-core` (Valet: `valet secure …`).
2. Set `APP_URL=https://externa-core.test` (must match the browser origin — RP ID / `allowed_origins`).
3. **Vite / Laravel `detectTls`:** Externa enables `detectTls` only when Herd cert files exist for the site. After `herd secure`, restart `npm run dev` so `public/hot` is `https://externa-core.test:5173` (not `127.0.0.1` — that causes a black/blank screen). After `herd unsecure`, restart Vite again so it falls back to HTTP without looking for missing certs.
4. Restart Vite (`npm run dev` / `composer run dev`). Hard-refresh the browser. If the session looks wrong after flipping HTTP↔HTTPS, clear cookies for both schemes for that host.

Optional local path without Herd TLS: browse `http://localhost` (or `*.localhost`) with matching `APP_URL` — that is a secure context for WebAuthn.

## Migration

Passkeys persist in the `passkeys` table (migration `create_passkeys_table`).

```bash
php artisan migrate
```

Schema (summary):

| Column          | Purpose                              |
| --------------- | ------------------------------------ |
| `user_id`       | FK to users, cascade on delete       |
| `name`          | Operator-facing device label         |
| `credential_id` | Unique WebAuthn credential id        |
| `credential`    | JSON credential payload              |
| `last_used_at`  | Updated on successful use (nullable) |

Fresh installs that already run `composer setup` / full migrate get this table automatically. Existing environments need one migrate after pulling the passkeys work.

## Security notes

- **No vendor forks** — stay on official Fortify and `@laravel/passkeys`. Do not patch RP validation, credential storage, or challenge handling in `vendor/`.
- **`confirmPassword` on manage** — default `Features::passkeys(['confirmPassword' => true])` so opening Security (and thus register/delete) requires a recent password confirmation. Keep this on unless you have a deliberate, reviewed exception.
- **Do not disable RP checks** — `relying_party_id` and `allowed_origins` exist to bind credentials to your site. Do not widen them to `*` or unrelated hosts.
- **Throttle** — Fortify registers a `passkeys` rate limiter alongside `login` / `two-factor`.
- **Ownership** — destroy is scoped to the authenticated user’s passkeys; deleting another user’s passkey returns an error and leaves the row intact.
- **MFA equivalence** — treat passkeys as a full MFA factor for `two_factor_required`, not as a weaker “remember me”. Prefer ≥2 authenticators (e.g. laptop + phone) so losing one device does not lock the account when the project requires MFA and TOTP is not enabled.

## Source map

| Concern                          | Location                                                                                   |
| -------------------------------- | ------------------------------------------------------------------------------------------ |
| Fortify feature + RP config      | `config/fortify.php`                                                                       |
| User traits                      | `App\Models\User` (`PasskeyUser`, `PasskeyAuthenticatable`)                                |
| Login redirect                   | `App\Http\Responses\PasskeyLoginResponse` (bound in `FortifyServiceProvider`)              |
| Security page props              | `App\Http\Controllers\Settings\SecurityController`                                         |
| MFA gate                         | `App\Http\Middleware\EnsureTwoFactorIsEnabled`                                             |
| Login UI                         | `resources/js/pages/auth/login.tsx`                                                        |
| Confirm-password UI              | `resources/js/pages/auth/confirm-password.tsx` (passkey confirm via `/passkeys/confirm/*`) |
| Security UI                      | `resources/js/pages/settings/security.tsx`                                                 |
| Vite TLS / hot URL (Herd secure) | `vite.config.ts` — Laravel plugin `detectTls: '<site>.test'`                               |
| Migration                        | `database/migrations/*_create_passkeys_table.php`                                          |
| Feature / browser tests          | `tests/Feature/Auth/PasskeysTest.php`, `tests/Browser/PasskeysBrowserTest.php`             |
