Approving on a phone (HTTPS + passkeys)
Passkeys only work in a secure context, on a hostname (WebAuthn does not accept IP addresses as relying-party ids). `http://localhost` is fine on the machine running the stack; a phone needs the approval app over HTTPS on a name it can resolve.
1. Pick a hostname the phone can reach
- **macOS:** your Bonjour name, `scutil --get LocalHostName` → e.g. `mymac` → `mymac.local`. - **Linux:** with Avahi running, `$(hostname).local`. - **Anything else, or a phone on another network:** use a tunnel (see the end).
Phone and computer must be on the same Wi-Fi. Check by opening `http://mymac.local:8081/healthz` on the phone.
2. Make a certificate with mkcert
brew install mkcert # or: apt install mkcert / choco install mkcert mkcert -install # creates a local CA and trusts it on this computer mkdir -p certs mkcert -cert-file certs/approve.pem -key-file certs/approve-key.pem mymac.local localhost chmod 644 certs/approve-key.pem # development only: lets the container user read it
`certs/` is git-ignored. Never commit keys.
3. Let the phone trust the mkcert CA
Find the CA with `mkcert -CAROOT` and send `rootCA.pem` to the phone (AirDrop, email).
- **iPhone/iPad:** open the file → Settings → _Profile Downloaded_ → Install. Then Settings → General → About → _Certificate Trust Settings_ → enable full trust for the mkcert root. - **Android:** Settings → Security → _Encryption & credentials_ → _Install a certificate_ → _CA certificate_.
Remove it again when you are done testing.
4. Configure and start
In `.env`:
RP_ID=mymac.local RP_ORIGIN=https://mymac.local:8081 APPROVE_URL=https://mymac.local:8081
With Docker:
docker compose -f compose.yaml -f infra/docker-compose.phone.yml up -d
Or set `APPROVE_TLS_CERT=certs/approve.pem` and `APPROVE_TLS_KEY=certs/approve-key.pem` in `.env`. Then a plain `docker compose up -d` serves HTTPS too (the stack mounts `certs/` read-only into the approval app), and so does `pnpm dev:approve` (plus `pnpm dev:api`) without Docker.
The API and the approval app must agree on `RP_ID`/`RP_ORIGIN`: the API verifies the passkey assertion against them.
5. Enroll and approve
pnpm demo
Open the printed **enroll** link on the phone and register a passkey (Face ID / fingerprint). Passkeys are bound to `RP_ID`, so one registered for `localhost` does not work for `mymac.local`; enroll again after changing it. Then open the **approve** link for the €1,600 payment and confirm. `pnpm demo --wait` prints the outcome once you approve.
Alternative: a tunnel
A tunnel gives a public HTTPS hostname with a real certificate and works on any network:
cloudflared tunnel --url http://localhost:8081 # or: ngrok http 8081
Set `RP_ID` to the tunnel hostname and `RP_ORIGIN`/`APPROVE_URL` to its `https://` URL, restart, and enroll again. This makes the approval page reachable from the internet; it is protected by unguessable 15-minute links and the passkey, but stop the tunnel when you are done.
Tested
The approval-page browser test (`apps/approve/src/approve.test.ts`) serves the app over HTTPS with a throwaway certificate and runs passkey enrollment and a €1,600 approval in Chromium with a virtual authenticator at phone width. A physical phone has not been tested in CI.