Docs · For users & developers

Authentication

VirtualID is passwordless. You sign in with a passkey (WebAuthn/FIDO2), and log in on new devices with a join code approved from a device you already trust. There is no password to phish, reuse, or leak.

This page explains VirtualID's sign-in and session model for a docs audience; the API surface is in the API reference.

Principles

  • No email required to create an account — your identity is a username you pick.
  • Fewest clicks possible — signup is pick username → create passkey → logged in.
  • Passkey is the credential — WebAuthn discoverable (resident) credentials, so returning users are offered their identity by the device.
  • Recovery is explicit, never an email password-reset bypass: recovery phrase / linked methods / optional TOTP.

Flow A — New account (signup)

  1. Pick a username. Single input with live validity hints.
  2. Next → client calls POST /auth/username/check. Available → green, and a Create passkey button appears. Taken/invalid → red with a reason.
  3. Create passkey → the browser's WebAuthn create prompt. The client also generates a non-exportable DPoP key (WebCrypto) at this step.
  4. POST /auth/register/finish with the attestation + DPoP proof → the server creates the account, stores the credential and dpop_jkt, and issues tokens. You're logged in instantly.

Usernames use casefolded uniqueness, a reserved-word list, and a confusable/homoglyph guard (anti-impersonation). Uniqueness is enforced atomically at register-finish — the availability check reserves nothing (prevents squatting via check spam).

Flow B — Returning user, this device has a passkey

  1. The login page immediately attempts discoverable login (conditional UI): navigator.credentials.get() with an empty allowlist, so the platform offers the identities stored on this device.
  2. You pick an identity, authenticate (biometric/PIN), and the client sends the assertion + a fresh DPoP key & proof to POST /auth/login/finish. Zero typing in the common case.

Flow C — New device, no passkey → cross-device join

To log in on a device that has no passkey yet, VirtualID uses a short join code generated on a device you're already logged into — then approved on that trusted device.

On the new device

  1. Choose Other and type your username.
  2. The server sees no credential here and offers Confirm with a join code.
  3. Enter the 6-digit join code from a device where you're already logged in.

On the trusted device

  1. Open Log in another device → the portal shows a 6-digit code (single-use, ~2-minute TTL) via POST /auth/join/issue.
  2. The new device submits {username, code} to POST /auth/join/redeem. This only moves the attempt to awaiting approval — it never logs anyone in.
  3. The trusted device shows an approval prompt with the new device's approximate location/UA. You must Approve (POST /auth/join/approve).
The code alone never grants login. join/finish returns 403 JOIN_NOT_APPROVED unless the request is approved on the trusted device. This is enforced server-side (proven by TestAuth_Join_RequiresApproval). If you have no trusted device, a recovery phrase or TOTP replaces the trusted-device approval.

Permanent-device opt-in

After approval, a deliberate checkbox — "This is a permanent device — trust it for future logins" — is unchecked by default:

  • Unchecked (default): Proceed gives you a DPoP-bound session for this session only. No passkey is created — right for a borrowed or shared machine.
  • Checked: Create passkey & proceed runs a WebAuthn create ceremony and registers a passkey for this device, so next time it's Flow B.

This maps to POST /auth/join/finish with persist_device: false issues an ephemeral session with no stored credential; true requires the WebAuthn ceremony and registers one credential.

Sessions & token binding (DPoP)

Sessions are opaque-token backed and cryptographically sender-bound with DPoP: a stolen bearer token is useless without the device's non-exportable DPoP private key. Tokens are short-lived and renewed via POST /auth/token/renew. The join-code path produces DPoP-bound sessions like any other.

Multiple methods, 2FA & recovery (MVP)

  • Multiple passkeys per account (phone + laptop + hardware key); list, rename, remove — but you cannot remove the last method.
  • A downloadable recovery/backup key, generated once (step-up to download).
  • Optional TOTP 2FA, with multiple authenticators (any valid code passes).
  • Recovery binding rule: if you have ≥1 TOTP authenticator, recovery needs the recovery key AND a valid TOTP code — the key alone is rejected. With no authenticator, the recovery key alone can recover (your explicit choice).
Deferred (post-MVP): OAuth logins (Google/Apple/Microsoft) — the auth provider layer is already designed as pluggable — plus SSO/SCIM and delegated OAuth apps for the developer platform.