Wocha Docs

Passkeys (WebAuthn)

Phishing-resistant authentication with passkeys in Wocha SDKs and Kratos

Passkeys are a modern, phishing-resistant authentication method built on the Web Authentication (WebAuthn) standard. Wocha uses Ory Kratos as the identity backend, which supports passkey registration and passwordless login natively. The Wocha SDKs expose this capability through React hooks, Next.js route handlers, and UI components.


What are passkeys?

A passkey is a cryptographic credential stored on a user's device (or synced via iCloud Keychain, Google Password Manager, etc.). Instead of typing a password, the user verifies their identity with a biometric or device PIN.

Benefits:

  • Phishing-resistant — credentials are bound to your site's domain (relying party ID)
  • No shared secrets — the private key never leaves the device
  • Better UX — one tap or biometric instead of remembering passwords
  • Sync across devices — platform providers sync passkeys where enabled

Enabling passkeys in Wocha

Passkeys are configured in Kratos. Ensure WebAuthn is enabled in your Kratos configuration:

selfservice:
  methods:
    webauthn:
      enabled: true
      config:
        passwordless: true   # allow passkey-only login
        rp:
          id: auth.example.com          # top-level domain (no scheme/port)
          display_name: My Application
          origins:
            - https://auth.example.com  # exact origin of the login page

Important settings:

SettingDescription
rp.idMust match the domain users authenticate against (e.g. example.com)
rp.originsMust include the exact URL (scheme + host + port) of pages that trigger WebAuthn
passwordlessWhen true, users can sign in with a passkey alone (no password)

For Wocha Cloud tenants, WebAuthn is enabled by default on the hosted auth UI. For self-hosted deployments, update your Kratos config and redeploy.


Passkeys count as multi-factor authentication

A passkey whose authenticator verifies the user — Touch ID, Face ID, Windows Hello, or a device PIN — is a multi-factor cryptographic authenticator (NIST SP 800-63B): possession of the key plus something the user is or knows. Wocha treats such a sign-in as urn:wocha:aal2, so:

  • A passkey user is not asked for a TOTP or SMS code after signing in, even in an organisation that requires MFA.
  • ID and access tokens carry acr: urn:wocha:aal2 and amr including swk (or hwk) and mfa.
  • urn:wocha:aal3 is reported only for a device-bound key, such as a hardware security key. Synced passkeys — including a phone passkey used through the QR code flow — are AAL2 at most, as NIST SP 800-63B-4 requires.
  • Registering a passkey completes required MFA setup.
  • A step-up request (acr_values=urn:wocha:aal2) is completed with the passkey.

Wocha verifies the passkey itself: the ceremony runs on Wocha's hosted sign-in, which requires user verification and refuses any assertion it cannot prove was user-verified. An authenticator that cannot verify the user (a PIN-less security key) can still be enrolled as a second factor, but cannot sign in passwordless.


Sign in with passkey

Passkeys are bound to one domain. Wocha runs every passkey sign-in on its own hosted page, so one passkey works in every Wocha app, and your app never handles WebAuthn. Your "Sign in with passkey" button sends the user to the hosted sign-in, which opens straight at the passkey step: the user taps once, confirms with Face ID / Touch ID / Windows Hello, and returns to your app signed in. An account without a passkey sees its usual sign-in methods.

If you already know who is signing in, pass their email as loginHint and the identifier step is skipped as well.

Next.js

import { PasskeySignInButton, signIn } from "@wocha/nextjs/client";
 
// A ready-made link…
<PasskeySignInButton returnTo="/dashboard" />
 
// …or from your own button:
<button onClick={() => signIn("/dashboard", { method: "passkey", loginHint: user?.email })}>
  Sign in with passkey
</button>

On the server, passkeySignInUrl(returnTo, basePath, loginHint) returns the same link.

React (browser apps)

import { useWochaAuth } from "@wocha/react";
 
function PasskeySignIn() {
  const { login } = useWochaAuth();
  return (
    <button onClick={() => void login({ method: "passkey" })}>
      Sign in with passkey
    </button>
  );
}

UI components

import { PasskeySignInButton, SignIn } from "@wocha/ui";
 
<PasskeySignInButton redirectUrl="/dashboard" />
 
// or on the pre-built sign-in card:
<SignIn passkeys providers={["google", "github"]} />

Other frameworks

SDKPasskey sign-in
@wocha/remixuseSignIn()("/dashboard", { method: "passkey" }) or passkeySignInUrl()
@wocha/sveltekitsignIn("/dashboard", "/auth", { method: "passkey" }) or passkeySignInUrl()
@wocha/nuxtuseWochaAuth().signIn("/dashboard", { method: "passkey" })
@wocha/vuesignIn({ method: "passkey" })
@wocha/angularauthService.signIn({ method: "passkey" })
@wocha/react-nativesignIn({ method: "passkey" })

Each also accepts loginHint.

Any OAuth client

Add two parameters to the authorization request:

ParameterMeaning
login_method=passkeyOpen the hosted sign-in at the passkey step
login_hint=<email>The identifier you already know (OIDC Core), optional

Adding passkeys to an account

Users add and remove passkeys in their Wocha account settings (the Passkeys section of the hosted settings page). Link to it from your app, or use the enrolment helpers below.

import { PasskeyButton } from "@wocha/ui";
 
<PasskeyButton mode="register" displayName="Work laptop">
  Add a passkey
</PasskeyButton>

PasskeyButton in register mode uses usePasskey().register() from @wocha/react, which calls your Next.js passkey routes (/api/auth/passkeys by default):

// app/api/auth/passkeys/register/options/route.ts
import { createPasskeyHandlers } from "@wocha/nextjs";
 
const { registerOptions } = createPasskeyHandlers({
  clientId: process.env.WOCHA_CLIENT_ID!,
  clientSecret: process.env.WOCHA_CLIENT_SECRET!,
  issuer: process.env.WOCHA_ISSUER!,
  kratosUrl: process.env.WOCHA_KRATOS_URL, // optional
});
 
export const POST = registerOptions;
RouteHandler export
POST /api/auth/passkeys/register/optionsregisterOptions
POST /api/auth/passkeys/register/verifyregisterVerify

Deprecated: embedded passkey sign-in

Earlier SDK versions ran the passkey sign-in inside your app. That only created a Wocha session in your server; Wocha could not verify the assertion, and the bridge that turned it into an app sign-in (POST {authBasePath}/passkey/continue) now answers 403 passkey_bridge_unavailable. These APIs are deprecated and will be removed in the next major version:

DeprecatedUse instead
createPasskeyHandlers().authenticateOptions / authenticateVerifysignIn(returnTo, { method: "passkey" }) / <PasskeySignInButton />
usePasskey().authenticate()useWochaAuth().login({ method: "passkey" })
useConditionalUI()login({ method: "passkey" }); passkey autofill on the hosted page is planned
passkeyContinueUrl()passkeySignInUrl()

PasskeyButton mode="authenticate" and <SignIn passkeys /> already use the hosted sign-in.


Browser support matrix

BrowserPasskeysConditional UINotes
Chrome 108+✅✅ (109+)Sync via Google Password Manager
Safari 16+✅✅ (16+)Sync via iCloud Keychain
Firefox 119+✅❌Passkeys work; autofill not yet supported
Edge 108+✅✅Same engine as Chrome
Safari iOS 16+✅✅Face ID / Touch ID
Chrome Android 108+✅PartialPlatform authenticator required

Use isWebAuthnSupported() and isConditionalMediationAvailable() to feature-detect at runtime.


Security considerations

  • Passkey routes must be served from the same origin as your app so Kratos CSRF cookies are forwarded correctly.
  • Set rp.id and rp.origins in Kratos to match your production domain — mismatches cause WebAuthn failures.
  • Registration typically requires an existing session (settings flow) or can be part of the signup flow depending on your Kratos configuration.
  • For OAuth-integrated apps, passkey login establishes a Kratos session; you may still need to complete the Hydra OAuth flow for API tokens depending on your architecture.
  • Do not lower userVerification when building custom UI on the low-level utilities: the BFF handler rejects assertions without user verification, and the sign-in would not count as MFA.

Troubleshooting

SymptomLikely cause
"WebAuthn is not supported"Non-HTTPS origin, or browser too old
"WebAuthn is not enabled" in API responseKratos webauthn.enabled: false or flow missing webauthn nodes
SecurityError in browserrp.id / origins mismatch with current URL
CSRF errors from KratosCookies not forwarded — ensure credentials: "include" on fetch
Passkey prompt never appearsUser has no registered passkeys for this RP ID

See also: Getting started