Passwordless Authentication

Complete guide to implementing passkey (WebAuthn) and magic link authentication flows

Updated Dec 16, 2025 Edit this page

Passwordless Authentication

This guide demonstrates how to implement passwordless authentication using passkeys (WebAuthn/FIDO2) and magic links. Passwordless authentication improves security by eliminating password-related vulnerabilities and provides a better user experience.

Overview

AuthOS supports two passwordless authentication methods:

  1. Passkeys (WebAuthn) - Biometric authentication using Touch ID, Face ID, Windows Hello, or hardware security keys
  2. Magic Links - One-time email links for authentication without passwords

Both methods eliminate the need for users to remember passwords while providing strong security guarantees.

Prerequisites

Before implementing passwordless authentication:

  • Install the SDK: npm install @drmhse/sso-sdk
  • Initialize the SSO client with your platform URL
  • Ensure your application uses HTTPS (required for WebAuthn)

Passkeys (WebAuthn) Authentication

Passkeys use the WebAuthn standard to authenticate users with biometrics or hardware security keys. This provides phishing-resistant authentication with an excellent user experience.

Browser Compatibility Check

Before implementing passkeys, check if the user’s browser supports WebAuthn:

import { SsoClient } from '@drmhse/sso-sdk';

const sso = new SsoClient({
  baseURL: 'https://sso.example.com'
});

// Check if WebAuthn is supported
if (!sso.passkeys.isSupported()) {
  console.log('Passkeys not supported - show password login instead');
  return;
}

// Check if platform authenticator is available (Touch ID, Face ID, etc.)
const hasPlatformAuth = await sso.passkeys.isPlatformAuthenticatorAvailable();
if (hasPlatformAuth) {
  console.log('Biometric authentication available');
}

Registering a Passkey

Users must register a passkey while authenticated. This is typically done from account settings after the user has logged in with another method.

async function registerPasskey() {
  // User must be authenticated first
  const accessToken = localStorage.getItem('sso_access_token');
  if (!accessToken) {
    throw new Error('User must be logged in to register a passkey');
  }

  sso.setAuthToken(accessToken);

  try {
    // Register the passkey with a friendly name
    const passkeyId = await sso.passkeys.register('My MacBook Pro');

    console.log('Passkey registered successfully:', passkeyId);
    // Show success message to user
    return passkeyId;
  } catch (error) {
    console.error('Passkey registration failed:', error);
    // Handle errors: user cancelled, authenticator not available, etc.
    throw error;
  }
}

Logging In with a Passkey

Once registered, users can authenticate using their passkey:

async function loginWithPasskey(email: string) {
  try {
    // Initiate passkey authentication
    const result = await sso.passkeys.login(email);

    // Store the token
    localStorage.setItem('sso_access_token', result.token);
    sso.setAuthToken(result.token);

    console.log('Logged in as:', result.user_id);

    // Redirect to dashboard
    window.location.href = '/dashboard';
  } catch (error) {
    if (error instanceof Error) {
      if (error.message.includes('no passkeys registered')) {
        // User has no passkeys - offer alternative login
        console.error('No passkeys registered for this account');
      } else {
        console.error('Passkey login failed:', error.message);
      }
    }
    throw error;
  }
}

Complete React Passkey Implementation

import { useState } from 'react';
import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';

const sso = new SsoClient({
  baseURL: process.env.REACT_APP_SSO_URL!
});

export function PasskeyLogin() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [isSupported, setIsSupported] = useState(true);

  // Check browser support on mount
  useEffect(() => {
    setIsSupported(sso.passkeys.isSupported());
  }, []);

  const handlePasskeyLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      const result = await sso.passkeys.login(email);

      // Store token and redirect
      localStorage.setItem('sso_access_token', result.token);
      sso.setAuthToken(result.token);
      window.location.href = '/dashboard';
    } catch (err) {
      setIsLoading(false);

      if (err instanceof SsoApiError) {
        if (err.statusCode === 404) {
          setError('No passkeys registered for this account. Please use another login method.');
        } else {
          setError(err.message);
        }
      } else if (err instanceof Error) {
        if (err.name === 'NotAllowedError') {
          setError('Authentication cancelled or timed out');
        } else {
          setError('Passkey authentication failed');
        }
      }
    }
  };

  if (!isSupported) {
    return (
      <div className="alert alert-warning">
        Your browser does not support passkeys. Please use a modern browser or try password login.
      </div>
    );
  }

  return (
    <div className="passkey-login">
      <h2>Sign in with Passkey</h2>
      <p>Use Touch ID, Face ID, or your security key to sign in securely</p>

      <form onSubmit={handlePasskeyLogin}>
        <div className="form-group">
          <label>Email Address</label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="you@example.com"
            required
          />
        </div>

        <button type="submit" disabled={isLoading}>
          {isLoading ? 'Authenticating...' : 'Sign in with Passkey'}
        </button>
      </form>

      {error && <div className="alert alert-error">{error}</div>}

      <div className="alternative-methods">
        <a href="/login/password">Sign in with password instead</a>
      </div>
    </div>
  );
}

export function PasskeyRegistration() {
  const [deviceName, setDeviceName] = useState('');
  const [error, setError] = useState('');
  const [success, setSuccess] = useState(false);
  const [isLoading, setIsLoading] = useState(false);

  const handleRegister = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setSuccess(false);
    setIsLoading(true);

    try {
      // User must already be authenticated
      const passkeyId = await sso.passkeys.register(deviceName || undefined);

      setSuccess(true);
      setDeviceName('');
      console.log('Registered passkey:', passkeyId);
    } catch (err) {
      setIsLoading(false);

      if (err instanceof Error) {
        if (err.name === 'NotAllowedError') {
          setError('Registration cancelled or not allowed');
        } else if (err.name === 'InvalidStateError') {
          setError('A passkey is already registered on this device');
        } else {
          setError(err.message);
        }
      }
    }
  };

  if (!sso.passkeys.isSupported()) {
    return (
      <div className="alert alert-warning">
        Your browser does not support passkeys
      </div>
    );
  }

  return (
    <div className="passkey-registration">
      <h3>Register a Passkey</h3>
      <p>Add a passkey to sign in faster and more securely</p>

      {success ? (
        <div className="alert alert-success">
          Passkey registered successfully! You can now use it to sign in.
        </div>
      ) : (
        <form onSubmit={handleRegister}>
          <div className="form-group">
            <label>Device Name (Optional)</label>
            <input
              type="text"
              value={deviceName}
              onChange={(e) => setDeviceName(e.target.value)}
              placeholder="e.g., My MacBook Pro"
            />
            <small>Give this passkey a name to identify which device it's on</small>
          </div>

          <button type="submit" disabled={isLoading}>
            {isLoading ? 'Registering...' : 'Register Passkey'}
          </button>
        </form>
      )}

      {error && <div className="alert alert-error">{error}</div>}
    </div>
  );
}

Magic links provide passwordless authentication by sending one-time login links via email. This is simpler to implement than passkeys and works on any device with email access.

async function requestMagicLink(email: string, orgSlug?: string) {
  try {
    const response = await sso.magicLinks.request({
      email,
      orgSlug // Optional: organization context
    });

    console.log(response.message); // "Magic link sent to your email"
    return true;
  } catch (error) {
    if (error instanceof SsoApiError) {
      if (error.statusCode === 429) {
        // Rate limit exceeded
        console.error('Too many requests. Please wait before trying again.');
      } else {
        console.error('Failed to send magic link:', error.message);
      }
    }
    return false;
  }
}

When users click the magic link in their email, your application needs to verify the token and complete authentication:

async function verifyMagicLink() {
  // Extract token from URL
  const urlParams = new URLSearchParams(window.location.search);
  const token = urlParams.get('token');

  if (!token) {
    console.error('No token provided');
    window.location.href = '/login';
    return;
  }

  try {
    // Verify the token
    const auth = await sso.magicLinks.verify(token);

    // Store tokens
    localStorage.setItem('sso_access_token', auth.access_token);
    localStorage.setItem('sso_refresh_token', auth.refresh_token);
    sso.setAuthToken(auth.access_token);

    // Redirect to application
    window.location.href = '/dashboard';
  } catch (error) {
    if (error instanceof SsoApiError) {
      if (error.statusCode === 404 || error.statusCode === 410) {
        console.error('Magic link has expired or been used');
      } else {
        console.error('Verification failed:', error.message);
      }
    }

    // Redirect back to login on error
    window.location.href = '/login?error=invalid_link';
  }
}
import { useState } from 'react';
import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';

const sso = new SsoClient({
  baseURL: process.env.REACT_APP_SSO_URL!
});

export function MagicLinkLogin() {
  const [email, setEmail] = useState('');
  const [submitted, setSubmitted] = useState(false);
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      await sso.magicLinks.request({
        email,
        orgSlug: 'acme-corp' // Optional organization context
      });

      setSubmitted(true);
    } catch (err) {
      setIsLoading(false);

      if (err instanceof SsoApiError) {
        if (err.statusCode === 429) {
          setError('Too many requests. Please wait a moment and try again.');
        } else {
          setError('Failed to send magic link. Please try again.');
        }
      }
    }
  };

  if (submitted) {
    return (
      <div className="magic-link-sent">
        <h2>Check your email</h2>
        <p>
          We sent a magic link to <strong>{email}</strong>
        </p>
        <p>Click the link in the email to sign in. The link expires in 15 minutes.</p>

        <button onClick={() => setSubmitted(false)}>
          Use a different email
        </button>
      </div>
    );
  }

  return (
    <div className="magic-link-login">
      <h2>Sign in with Magic Link</h2>
      <p>Enter your email address and we'll send you a link to sign in</p>

      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label>Email Address</label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="you@example.com"
            required
            autoFocus
          />
        </div>

        <button type="submit" disabled={isLoading}>
          {isLoading ? 'Sending...' : 'Send Magic Link'}
        </button>
      </form>

      {error && <div className="alert alert-error">{error}</div>}

      <div className="alternative-methods">
        <a href="/login/password">Sign in with password instead</a>
      </div>
    </div>
  );
}

export function MagicLinkVerify() {
  const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying');
  const [errorMessage, setErrorMessage] = useState('');

  useEffect(() => {
    verifyToken();
  }, []);

  const verifyToken = async () => {
    const urlParams = new URLSearchParams(window.location.search);
    const token = urlParams.get('token');

    if (!token) {
      setStatus('error');
      setErrorMessage('No verification token found');
      return;
    }

    try {
      const auth = await sso.magicLinks.verify(token);

      // Store tokens
      localStorage.setItem('sso_access_token', auth.access_token);
      localStorage.setItem('sso_refresh_token', auth.refresh_token);
      sso.setAuthToken(auth.access_token);

      setStatus('success');

      // Redirect after brief success message
      setTimeout(() => {
        window.location.href = '/dashboard';
      }, 1500);
    } catch (error) {
      setStatus('error');

      if (error instanceof SsoApiError) {
        if (error.statusCode === 404) {
          setErrorMessage('This magic link has expired or been used');
        } else if (error.statusCode === 410) {
          setErrorMessage('This magic link has already been used');
        } else {
          setErrorMessage('Verification failed. Please request a new link.');
        }
      } else {
        setErrorMessage('An unexpected error occurred');
      }
    }
  };

  if (status === 'verifying') {
    return (
      <div className="verifying">
        <h2>Verifying your magic link...</h2>
        <div className="spinner"></div>
      </div>
    );
  }

  if (status === 'success') {
    return (
      <div className="success">
        <h2>Success!</h2>
        <p>Redirecting to your dashboard...</p>
      </div>
    );
  }

  return (
    <div className="error">
      <h2>Verification Failed</h2>
      <p>{errorMessage}</p>
      <a href="/login">Request a new magic link</a>
    </div>
  );
}

Best Practices

Passkey Security

  1. Always use HTTPS: WebAuthn requires a secure context
  2. Prevent account lockout: Always offer alternative authentication methods
  3. User-friendly device names: Encourage users to name their passkeys
  4. Handle errors gracefully: Users may cancel authentication or not have a compatible device
  5. Support multiple passkeys: Allow users to register passkeys on multiple devices
  1. Short expiration times: Links should expire quickly (15 minutes recommended)
  2. One-time use: Tokens should be invalidated after first use
  3. Rate limiting: Prevent abuse by limiting magic link requests
  4. Clear email content: Make emails recognizable to prevent phishing
  5. Secure token generation: Use cryptographically secure random tokens

User Experience

  1. Progressive enhancement: Detect browser capabilities and offer appropriate methods
  2. Clear instructions: Guide users through the authentication process
  3. Alternative methods: Always provide fallback authentication options
  4. Loading states: Show clear feedback during authentication
  5. Error messages: Provide actionable error messages

Implementation Checklist

  • Check browser support before showing passkey options
  • Implement proper error handling for WebAuthn exceptions
  • Configure appropriate magic link expiration times
  • Add rate limiting for magic link requests
  • Test on multiple devices and browsers
  • Provide clear user instructions
  • Implement fallback authentication methods
  • Monitor authentication success rates
  • Handle edge cases (expired links, cancelled authentication)

Combining Methods

For the best user experience, combine multiple passwordless methods:

export function UnifiedPasswordlessLogin() {
  const [email, setEmail] = useState('');
  const [method, setMethod] = useState<'passkey' | 'magic_link'>('passkey');

  const hasPasskeySupport = sso.passkeys.isSupported();

  // Auto-select magic link if passkeys not supported
  useEffect(() => {
    if (!hasPasskeySupport) {
      setMethod('magic_link');
    }
  }, [hasPasskeySupport]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (method === 'passkey' && hasPasskeySupport) {
      // Try passkey login
      try {
        const result = await sso.passkeys.login(email);
        // Handle success
      } catch (error) {
        // Fallback to magic link on error
        console.log('Passkey failed, sending magic link');
        await sso.magicLinks.request({ email });
      }
    } else {
      // Use magic link
      await sso.magicLinks.request({ email });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />

      {hasPasskeySupport && (
        <div className="method-selector">
          <label>
            <input
              type="radio"
              checked={method === 'passkey'}
              onChange={() => setMethod('passkey')}
            />
            Use Passkey (Touch ID / Face ID)
          </label>
          <label>
            <input
              type="radio"
              checked={method === 'magic_link'}
              onChange={() => setMethod('magic_link')}
            />
            Email me a magic link
          </label>
        </div>
      )}

      <button type="submit">
        {method === 'passkey' ? 'Sign in with Passkey' : 'Send Magic Link'}
      </button>
    </form>
  );
}

Troubleshooting

Passkey Issues

“WebAuthn not supported”

  • Ensure the site is accessed via HTTPS (localhost is exempt)
  • Check browser compatibility (Chrome 67+, Safari 14+, Firefox 60+)
  • Verify user has a compatible authenticator

“Registration failed”

  • User may have cancelled the operation
  • Authenticator might already have a credential for this site
  • Check for conflicting browser extensions

“Authentication failed”

  • User may have no registered passkeys
  • Passkey might have been removed from the authenticator
  • Network or server errors

“Magic link not received”

  • Check spam/junk folder
  • Verify email configuration on the SSO platform
  • Check rate limits

“Magic link expired”

  • Links expire after 15 minutes by default
  • User needs to request a new link

“Token already used”

  • Magic links are single-use for security
  • User needs to request a new link