HelloJohn / docs
MFA

WebAuthn / Passkeys

Enable WebAuthn and passkey authentication in HelloJohn — hardware security keys, Touch ID, Face ID, and Windows Hello as MFA or primary auth.

WebAuthn (Web Authentication API) enables authentication using hardware security keys (YubiKey, etc.) and platform authenticators (Touch ID, Face ID, Windows Hello). These are the most phishing-resistant MFA methods available.

WebAuthn vs. Passkeys

WebAuthn (security keys)Passkeys
DeviceExternal hardware key (YubiKey)Built-in (Touch ID, Face ID, Windows Hello)
Syncs across devicesNoYes (via iCloud Keychain, Google Password Manager)
Phishing resistanceHighestHigh
Use as 2nd factorYesYes
Use as primary authYes (passwordless)Yes (passwordless)

HelloJohn supports both via the same WebAuthn API — the browser determines which authenticator is used.

Browser support

BrowserPlatform authenticatorsSecurity keys
Chrome 67+YesYes
Safari 14+ (iOS/macOS)Yes (Touch ID / Face ID)Yes
Firefox 60+YesYes
Edge 18+Yes (Windows Hello)Yes

Enrollment flow

Initiate enrollment

POST /v2/auth/mfa/webauthn/enroll
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "authenticator_type": "platform"  // "platform" (passkey) or "cross-platform" (security key)
}

Response:

{
  "enrollment_id": "enroll_01HX...",
  "options": {
    "challenge": "base64url-encoded-challenge",
    "rp": { "id": "yourapp.com", "name": "Your App" },
    "user": { "id": "base64url-user-id", "name": "user@example.com", "displayName": "User" },
    "pubKeyCredParams": [{ "type": "public-key", "alg": -8 }],
    "authenticatorSelection": {
      "authenticatorAttachment": "platform",
      "residentKey": "preferred",
      "userVerification": "preferred"
    },
    "timeout": 60000
  }
}

Browser creates the credential

Pass the options to the browser's WebAuthn API:

const credential = await navigator.credentials.create({
  publicKey: options
})

The browser prompts the user to authenticate (Face ID, fingerprint, PIN, etc.) and creates a public/private key pair. The private key never leaves the device.

Complete enrollment

Send the credential back to HelloJohn:

POST /v2/auth/mfa/webauthn/enroll/complete
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "enrollment_id": "enroll_01HX...",
  "credential": {
    "id": "credential-id",
    "rawId": "base64url-raw-id",
    "response": {
      "clientDataJSON": "base64url...",
      "attestationObject": "base64url..."
    },
    "type": "public-key"
  }
}

Response 200 OK:

{
  "method_id": "mfa_01HX...",
  "backup_codes": ["XXXX-XXXX", ...]
}

Verification (login challenge)

# Get challenge
POST /v2/auth/mfa/webauthn/challenge
Content-Type: application/json

{
  "challenge_id": "challenge_01HX..."
}

Response:

{
  "options": {
    "challenge": "base64url...",
    "allowCredentials": [{ "id": "credential-id", "type": "public-key" }],
    "timeout": 60000,
    "userVerification": "preferred"
  }
}

Browser gets the assertion:

const assertion = await navigator.credentials.get({ publicKey: options })

Submit the assertion:

POST /v2/auth/mfa/webauthn/verify
Content-Type: application/json

{
  "challenge_id": "challenge_01HX...",
  "assertion": {
    "id": "credential-id",
    "rawId": "base64url...",
    "response": {
      "authenticatorData": "base64url...",
      "clientDataJSON": "base64url...",
      "signature": "base64url..."
    },
    "type": "public-key"
  }
}

SDK integration

import { useWebAuthn } from '@hellojohn/react'

function WebAuthnSetup() {
  const { enroll, isSupported } = useWebAuthn()

  if (!isSupported) {
    return <p>Your browser doesn't support passkeys.</p>
  }

  return (
    <button onClick={() => enroll({ type: 'platform' })}>
      Set up Face ID / Touch ID
    </button>
  )
}

function WebAuthnChallenge({ challengeId }: { challengeId: string }) {
  const { verify } = useWebAuthn()

  return (
    <button onClick={() => verify(challengeId)}>
      Verify with Face ID / Touch ID
    </button>
  )
}

The SDK handles the full WebAuthn ceremony (options fetch → browser API → assertion submit) in a single call.

Relying Party configuration

Configure the WebAuthn Relying Party in your environment:

WEBAUTHN_RP_ID=yourapp.com         # must match your app's domain
WEBAUTHN_RP_NAME=Your App          # display name shown in browser prompts
WEBAUTHN_RP_ORIGINS=https://yourapp.com,https://www.yourapp.com

WEBAUTHN_RP_ID must exactly match the origin domain. If your app is at app.yourapp.com, set WEBAUTHN_RP_ID=app.yourapp.com. Passkeys created for one RP ID won't work on another.

On this page