rpID setup — WebAuthn HTTPS strategy
Stellar Passkey Kit — developer reference. Sources: W3C WebAuthn L3 §5.1.3 (rpId scoping), Cloudflare Tunnel docs (named & quick tunnels).
WebAuthn passkey ceremonies (navigator.credentials.create() and .get()) require a secure context — that means HTTPS, or the literal hostname localhost / 127.0.0.1. Anything else (a LAN IP, 192.168.x.x, a co-worker's machine over HTTP, a random preview URL on plain HTTP) is rejected by the browser before a prompt ever appears.
This document explains the three environments where we run passkey flows — production, preview / CI, and local dev — and the rpId rules that bind them together.
1. rpId scoping rules — read this first
The Relying Party ID (rpId) is the registrable suffix that a passkey is bound to. The browser enforces these rules and silently no-ops if they are violated:
rpIdMUST be a valid domain string (no scheme, no port, no path).rpIdMUST equalwindow.location.hostnameor be a registrable parent of it. Examples below.- A passkey created with
rpId = Xwill only be usable on origins whose hostname isXor a subdomain ofX. There is no cross-registrable-domain portability — by design. - The "registrable" part means the eTLD+1 boundary (the Public Suffix List). You cannot set
rpId = "dev"orrpId = "pages.dev"— those are public suffixes, not registrable. - Default when
rpIdis omitted: the browser useswindow.location.hostnameverbatim.
| Page is served from | Allowed rpId values | Disallowed |
|---|---|---|
passkey.example.dev | passkey.example.dev, example.dev | dev, example.com, other.example.dev |
demo.example.dev | demo.example.dev, example.dev | (cannot use passkey.example.dev) |
app.pages.dev | app.pages.dev | pages.dev (public suffix) |
localhost | localhost | anything else |
Practical rule for this project: pick the broadest rpId that covers every origin where the kit will be used. For the SCF-43 sprint we use passkey.<yk-domain> per environment and set rpId to the eTLD+1 so passkeys made in preview also work in production once the user is ready to migrate.
2. Production
- Hosting: Cloudflare Pages.
- Domain:
passkey.<yk-domain>(registered Cloudflare zone, e.g.passkey.yk-labs.dev— final domain TBD by YK-239 follow-up). - rpId: the eTLD+1, e.g.
yk-labs.dev. Allows preview and production subdomains to share a credential namespace if we ever migrate one. - HTTPS: automatic via Cloudflare's edge cert. Nothing to configure in app code.
Demo deploys are triggered from main of apps/demo and land at https://passkey.<yk-domain> via the Pages project bound to that subdomain.
3. Preview / CI
- Hosting: Cloudflare Pages preview deployments (per-PR URL).
- Domain pattern:
https://<sha>.<project>.pages.dev. - rpId:
<project>.pages.dev— butpages.devitself is a public suffix, so we must setrpId = <project>.pages.devexactly (not justpages.dev). - Caveat: passkeys created against
<project>.pages.devare usable on any subdomain of that project only. They will not transfer to the production rpId, which is fine — preview credentials are throwaway by design.
CI uses the same preview URL when running browser-based smoke tests; the Playwright virtual-authenticator does not need DNS, but Safari-via-safaridriver does, so the preview URL is the canonical CI target.
4. Local dev — three options
4.1 Plain localhost (fastest)
Run the Vite demo:
pnpm --filter @stellar-passkey/demo dev
# → http://localhost:5173localhost is a secure context. Set rpId = "localhost". Credentials made here only work on localhost — they do not transfer to staging or production.
4.2 Quick tunnel (no Cloudflare login)
For testing on a phone, in a VM, or with Safari, use a quick tunnel. Hostname is random and ephemeral; great for ad-hoc checks:
cloudflared tunnel --url http://localhost:5173
# Logs print a URL like:
# https://crispy-fox-1234.trycloudflare.comSet rpId to that random hostname. Credentials are throwaway — the URL dies when you kill cloudflared.
4.3 Named tunnel with stable hostname (best for shared dev)
Once a Cloudflare account is wired up and a domain exists on Cloudflare:
cloudflared tunnel login # opens browser, picks the zone
cloudflared tunnel create stellar-passkey
cloudflared tunnel route dns stellar-passkey passkey.<your-domain>
cloudflared tunnel --config cloudflared/config.yml run stellar-passkeySee cloudflared/config.example.yml for the file the last command expects. The tunnel exposes http://localhost:5173 at https://passkey.<your-domain> with a real cert. Set rpId = "<your-domain>" so credentials made locally also work in preview/production.
The first three commands are one-time setup per workstation. After that,
cloudflared tunnel run stellar-passkeyis the only command you'll re-run.
5. SDK convention
Code that initiates a ceremony must read rpId from configuration, not hard-code the production domain. Example:
import { startCreate } from "@stellar-passkey/core";
await startCreate({
rpId: import.meta.env.VITE_RP_ID ?? window.location.hostname,
rpName: "Stellar Passkey Kit Demo",
// …
});VITE_RP_ID is read from .env.local (gitignored) in dev, and from Cloudflare Pages env vars in preview/production.
6. Gotchas captured from past incidents
- Setting
rpIdto a public suffix (e.g."pages.dev","dev") — the browser rejects the ceremony with a genericNotAllowedError. Always pick the eTLD+1 or a deeper label. - Mixing rpIds across environments — a passkey made with
rpId = "localhost"is unusable onpasskey.example.devand vice versa. Plan the rpId before you ship the first credential into a user's keychain. - Mobile testing via LAN IP — does not work. Use a tunnel (§4.2 or §4.3) to get HTTPS to the phone.
- CI flake from random tunnel hostnames — the rpId per CI job changes every run. Use a named tunnel for any test asserting on rpId persistence.
- Apple Passkeys and
rpIdchange — if you change the rpId of an existing relying party, Apple's keychain treats the new and old as separate apps; user has to enroll a new passkey.
7. Status (YK-239)
- [x]
docs/dev/rpid-setup.mdwritten (this file). - [x]
cloudflared≥ 2026.2.0 installed locally. - [x]
cloudflared/config.example.ymltemplate committed. - [ ] Cloudflare zone selected and
passkey.<domain>DNS routed — pending: domain choice +cloudflared tunnel login(requires browser auth as the Cloudflare account owner). - [ ]
curl -fsSL https://passkey.<domain>returns 200 — pending the step above.