Handles are public
A handle names an account in public. It is the account’s login name, relying
parties receive it as preferred_username, and published profiles will live at
sourceorigin.dev/@handle (#101).
Auth doesn’t hide which handles have an account:
- Handle sign-in refuses a handle without an active account at once: “No active
account has this handle.” (
start_logininapps/auth-service/src/passkey/mod.rs). - Signup and renames say why a handle can’t be taken, naming the look-alike
that blocks it (
Handles::claimableinapps/auth-service/src/handles/mod.rs).
This was decided in #255.
Why handles aren’t hidden
- Uniqueness gives handles away. Signup has to refuse a handle that’s taken, and so do renames. Anyone who can sign up, or who holds an account, can therefore check any handle. A vaguer refusal would only stop naming the look-alike; it would still say the typed handle is unavailable. Production requires invitations, which limits signup to people with one, but open signup would extend this to everyone.
- Handles are already published: to relying parties, and in profiles once #101 ships.
- Decoys would hide little. Answering handle sign-in for an unknown handle with stable fake credentials, derived from a server key, gives them fixed shapes, so accounts with other passkeys stand out. A disabled account would visibly switch to decoys, and every change of the key changes them, so anyone comparing responses from before and after learns which handles exist (#181).
What a handle reveals
| Who | Learns |
|---|---|
| Anyone, through handle sign-in | Whether the handle belongs to an account that can sign in with a passkey by handle: one that is active, or scheduled for deletion, and has an unrevoked passkey. If so, that account’s credential IDs too, which WebAuthn lists for the browser: how many passkeys it has and, from their lengths, often which provider holds them. |
| Anyone admitted to signup, and every account holder through a rename | Whether the handle, or a look-alike, is held or was held, and which look-alike. |
| Relying parties the user signs in to | The handle, as preferred_username. |
| Anyone, once a profile is published (#101) | The profile. #101 has an unpublished profile answer 404 like an unknown handle, so it reveals nothing beyond what sign-in does. |
The account picker (signing in without a handle) reveals nothing: the authenticator names the account.
Threat model
Knowing that a handle has an account gives an attacker little:
- Nothing to guess. Sign-in needs a passkey. A handle doesn’t help forge one, and passkeys resist phishing.
- No lockout. Rate limits count requests per client IP, never per account
(
apps/auth-service/src/security/mod.rs), so naming an account can’t lock its owner out. - Not evidence. Support’s evidence policy never counts knowing a handle (account recovery).
- Credential IDs were never hidden. Handle sign-in lists a real account’s credential IDs with or without decoys. They only work for auth’s RP ID, and are useless without the passkey’s private key.
The cost is privacy: anyone can check whether a person, by the username they use elsewhere, has an Origin Account. For the sign-in of SourceOrigin’s own apps, that is accepted.
Limits
- Anyone can add failed sign-ins to an account’s history. Handle sign-in
binds its ceremony to the account, so a failed assertion, even from a passkey
that was never registered, is recorded against it as
SignInFailed. The owner’s history hides these, but support reads them in the account’s events. Per-IP rate limits slow a single source, but nothing bounds the failures across sources. The failures change nothing: they are noise, not progress. - Credential IDs link a handle across renames. Anyone who recorded an account’s credential IDs before a rename can find its new handle by trying candidates. #297 decides what to do about it.