Banata

Authentication

Passkeys

WebAuthn-based passwordless authentication using biometrics, security keys, or device credentials.

Passkeys provide passwordless authentication using the WebAuthn standard. Users authenticate with their device's biometric sensor (Face ID, Touch ID, Windows Hello), a hardware security key (YubiKey), or a device PIN. Passkeys are phishing-resistant, eliminate password reuse, and provide the strongest form of user authentication available today.

Banata Auth supports passkeys via the passkey plugin, built on the WebAuthn / FIDO2 specification.


How It Works

Passkeys involve two distinct flows: registration (creating a credential) and authentication (using a credential to sign in).

Registration Flow

typescript
1. User is signed in and navigates to security settings
2. User clicks "Add Passkey"
3. Client calls authClient.passkey.addPasskey()
4. Server generates a challenge and returns registration options
5. Browser triggers the WebAuthn API (navigator.credentials.create())
6. User completes the biometric/PIN/security key prompt
7. Browser returns the attestation response to the client
8. Client sends the attestation to the server for verification
9. Server validates the response and stores the credential public key
10. Passkey is now registered and ready for authentication

Authentication Flow

typescript
1. User navigates to the sign-in page
2. User clicks "Sign in with Passkey"
3. Client calls authClient.signIn.passkey()
4. Server generates an authentication challenge
5. Browser triggers the WebAuthn API (navigator.credentials.get())
6. User completes the biometric/PIN/security key prompt
7. Browser returns the assertion response to the client
8. Client sends the assertion to the server for verification
9. Server validates the signature against the stored public key
10. Session is created and user is authenticated

Configuration

Enable passkeys in your BanataAuthConfig:

typescript
// convex/banataAuth/auth.ts
function buildConfig(): BanataAuthConfig {
  return {
    appName: "My App",
    siteUrl: process.env.SITE_URL!,
    secret: process.env.BETTER_AUTH_SECRET!,
 
    authMethods: {
      passkey: true,  // Enable passkey (WebAuthn) authentication
    },
 
    passkey: {
      rpId: process.env.PASSKEY_RP_ID ?? "localhost",
      rpName: "My App",
      origin: process.env.PASSKEY_ORIGIN ?? "http://localhost:3000",
    },
  };
}

Passkey Configuration Options

OptionTypeRequiredDescription
rpIdstringYesRelying Party ID. Must be the domain of your app (e.g., "myapp.com"). Use "localhost" in development.
rpNamestringYesHuman-readable name shown in the browser's passkey prompt.
originstringYesThe full origin of your app (e.g., "https://myapp.com"). Must match the page origin exactly.

Important: The rpId must be a valid domain that matches or is a registrable suffix of the page origin. For example, if your app is at https://app.mycompany.com, the rpId can be "app.mycompany.com" or "mycompany.com", but not "other.com".

Environment Variables

bash
# .env
PASSKEY_RP_ID=myapp.com
PASSKEY_ORIGIN=https://myapp.com

In development:

bash
# .env.local
PASSKEY_RP_ID=localhost
PASSKEY_ORIGIN=http://localhost:3000

Browser and Platform Support

Passkeys are supported on all modern browsers and platforms:

PlatformSupport
Chrome (Desktop)67+ (Windows Hello, security keys)
Chrome (Android)67+ (fingerprint, screen lock)
Safari (macOS)13+ (Touch ID, security keys)
Safari (iOS)14+ (Face ID, Touch ID)
Firefox60+ (security keys); 122+ (platform authenticators)
Edge18+ (Windows Hello, security keys)

Cross-Device Authentication

Modern passkey implementations support cross-device authentication, where a user can use their phone to authenticate on a desktop browser via Bluetooth proximity. This is handled natively by the browser and operating system -- no additional configuration is required.


Client-Side API

Register a Passkey

Users must be signed in to register a passkey. This is typically done in a security settings page:

typescript
import { authClient } from "@/lib/auth-client";
 
const { data, error } = await authClient.passkey.addPasskey();
 
if (error) {
  // Handle errors:
  // - "WebAuthn not supported" (browser doesn't support passkeys)
  // - "Registration failed" (user cancelled or hardware error)
  // - "Passkey already registered" (duplicate credential)
  console.error(error.message);
} else {
  console.log("Passkey registered successfully");
}

Sign In with a Passkey

typescript
const { data, error } = await authClient.signIn.passkey();
 
if (error) {
  // Handle errors:
  // - "Authentication failed" (invalid credential or user cancelled)
  // - "No passkeys found" (no credentials registered for this rpId)
  console.error(error.message);
} else {
  // data contains the user and session
  console.log(data.user);
  console.log(data.session);
  window.location.href = "/dashboard";
}

List Registered Passkeys

typescript
const { data, error } = await authClient.passkey.listPasskeys();
 
if (data) {
  // data is an array of registered passkeys
  data.forEach((passkey) => {
    console.log(passkey.id);          // Credential ID
    console.log(passkey.createdAt);   // When it was registered
    console.log(passkey.deviceType);  // "platform" or "cross-platform"
  });
}

Delete a Passkey

typescript
const { error } = await authClient.passkey.deletePasskey({
  id: "credential_id_here",
});
 
if (!error) {
  console.log("Passkey removed");
}

Complete Registration Component

tsx
"use client";
 
import { authClient } from "@/lib/auth-client";
import { useState, useEffect } from "react";
 
export function PasskeySettings() {
  const [passkeys, setPasskeys] = useState<any[]>([]);
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
 
  useEffect(() => {
    loadPasskeys();
  }, []);
 
  async function loadPasskeys() {
    const { data } = await authClient.passkey.listPasskeys();
    if (data) setPasskeys(data);
  }
 
  async function handleAdd() {
    setError("");
    setLoading(true);
 
    const { error } = await authClient.passkey.addPasskey();
 
    if (error) {
      setError(error.message ?? "Failed to register passkey");
    } else {
      await loadPasskeys();
    }
 
    setLoading(false);
  }
 
  async function handleDelete(id: string) {
    const { error } = await authClient.passkey.deletePasskey({ id });
 
    if (!error) {
      setPasskeys((prev) => prev.filter((p) => p.id !== id));
    }
  }
 
  return (
    <div>
      <h2>Passkeys</h2>
      <p>
        Use your device's biometric sensor or security key to sign in
        without a password.
      </p>
 
      {passkeys.length > 0 ? (
        <ul>
          {passkeys.map((passkey) => (
            <li key={passkey.id}>
              <span>
                {passkey.deviceType === "platform"
                  ? "This device"
                  : "Security key"}{" "}
                -- Added {new Date(passkey.createdAt).toLocaleDateString()}
              </span>
              <button onClick={() => handleDelete(passkey.id)}>
                Remove
              </button>
            </li>
          ))}
        </ul>
      ) : (
        <p>No passkeys registered yet.</p>
      )}
 
      {error && <p style={{ color: "red" }}>{error}</p>}
 
      <button onClick={handleAdd} disabled={loading}>
        {loading ? "Waiting for device..." : "Add Passkey"}
      </button>
    </div>
  );
}

Sign-In Page with Passkey Option

tsx
"use client";
 
import { authClient } from "@/lib/auth-client";
import { useState } from "react";
 
export function SignInPage() {
  const [error, setError] = useState("");
 
  async function handlePasskeySignIn() {
    setError("");
    const { data, error } = await authClient.signIn.passkey();
 
    if (error) {
      setError(error.message ?? "Passkey sign-in failed");
    } else {
      window.location.href = "/dashboard";
    }
  }
 
  return (
    <div>
      <h1>Sign In</h1>
 
      <button onClick={handlePasskeySignIn}>
        Sign in with Passkey
      </button>
 
      {error && <p style={{ color: "red" }}>{error}</p>}
 
      <hr />
      <p>Or sign in with email and password:</p>
      {/* Email/password form here */}
    </div>
  );
}

Resident vs. Non-Resident Credentials

WebAuthn defines two types of credentials:

TypeAlso CalledDescription
Resident (discoverable)PasskeyStored on the device. The user does not need to provide a username first -- the browser can enumerate available credentials for the site.
Non-resident (non-discoverable)Server-side credentialThe credential ID is stored on the server. The user must identify themselves first (e.g., enter their email), then the server provides the credential ID to the browser for the authentication challenge.

Banata Auth defaults to resident credentials (true passkeys). This means:

  • Users can sign in by clicking "Sign in with Passkey" without entering an email first
  • The browser shows a credential picker if multiple passkeys are registered for the site
  • Credentials sync across devices via iCloud Keychain (Apple), Google Password Manager (Android/Chrome), or Windows Hello

Combining with Other Methods

Passkeys work well alongside traditional authentication methods. Users can register a passkey after signing in with email/password, and then use the passkey for future sign-ins:

typescript
authMethods: {
  emailPassword: true,   // Traditional fallback
  passkey: true,          // Passwordless primary
  magicLink: true,        // Another passwordless option
},
 
passkey: {
  rpId: process.env.PASSKEY_RP_ID ?? "localhost",
  rpName: "My App",
  origin: process.env.PASSKEY_ORIGIN ?? "http://localhost:3000",
},

A recommended UI pattern is to show the passkey option prominently, with email/password as a secondary option:

typescript
+----------------------------------+
|  [Sign in with Passkey]          |  <-- Primary, prominent
+----------------------------------+
|                                  |
|  -------- or continue with ----- |
|                                  |
|  Email:    [____________]        |
|  Password: [____________]        |
|  [Sign In]                       |
+----------------------------------+

Audit Events

EventWhen
passkey.registeredNew passkey credential registered
passkey.deletedPasskey credential removed
passkey.authenticatedSuccessful authentication via passkey
session.createdSession created after passkey authentication

See the Audit Logs guide for more details.


Security Advantages Over Passwords

PropertyPasswordsPasskeys
Phishing resistanceLow -- users can enter passwords on fake sitesHigh -- credentials are bound to the origin
Credential reuseCommon -- users reuse passwords across sitesImpossible -- each credential is site-specific
Brute forcePossible if rate limiting is weakNot applicable -- no shared secret to guess
Data breachesLeaked password hashes can be crackedPublic keys only -- useless to attackers
User frictionHigh -- users must remember passwordsLow -- biometric or PIN, no memorization

Passkeys are the most secure user authentication method available in web browsers today. The private key never leaves the user's device, and authentication requires physical presence (biometric or security key interaction), making remote attacks effectively impossible.


Security Considerations

  1. Always validate the origin -- The origin in your passkey config must exactly match the page origin. Mismatches will cause authentication failures.
  2. Use HTTPS in production -- WebAuthn requires a secure context. It works on localhost for development but requires HTTPS in production.
  3. Encourage multiple passkeys -- Users should register passkeys on multiple devices to avoid lockout if a single device is lost.
  4. Provide fallback methods -- Not all users have passkey-capable devices. Always offer an alternative sign-in method (email/password, magic link, etc.).
  5. Credential backup -- Platform passkeys (iCloud Keychain, Google Password Manager) are backed up and sync across devices. Hardware security key credentials are not -- losing the key means losing access.

Troubleshooting

"WebAuthn not supported"

The user's browser does not support the WebAuthn API. This is rare on modern browsers but can happen on older versions or certain embedded browser views. Check window.PublicKeyCredential before showing the passkey option.

"Registration cancelled"

The user dismissed the browser's passkey prompt. This is not an error -- simply allow the user to try again.

"Origin mismatch"

The origin in your passkey config does not match the actual page origin. Ensure PASSKEY_ORIGIN matches exactly (including protocol and port).

"Passkey not found"

No registered credentials match the rpId. This can happen if:

  • The user hasn't registered a passkey for this site
  • The rpId changed between registration and authentication
  • The user's passkey was deleted from their device

What's Next