DEV Community

Onwuka David
Onwuka David

Posted on

Passkey Login & Smart Wallet Creation on Solana with React Native and LazorKit — No More Seed Phrases!

Time to complete: 15-20 minutes

A significant percentage of crypto users have permanently lost access to their wallets due to forgotten seed phrases. But what if seed phrases weren’t required at all?

In this tutorial, you’ll learn how to build a passwordless Solana wallet using LazorKit’s passkey integration using biometric authentication like Face ID and Touch ID. The result is Web2-level UX with Web3-grade security—no extensions, no seed phrases.

By the end, you’ll understand how passkeys work and have a fully functional smart wallet connection flow running on Solana.

Who this tutorial is for

  • Developers familiar with React Native

  • Anyone curious about replacing seed phrases with passkeys

  • Builders exploring gasless Solana UX with LazorKit

What are Passkeys?

Passkeys are a modern authentication standard (WebAuthn) that replaces passwords and seed phrases with biometric authentication.

Forget long seed phrases and confusing backups. With passkeys, users access their wallets using FaceID, TouchID, or Windows Hello, dramatically improving the user experience of crypto applications while maintaining strong security guarantees.

Before we dive in properly, let's look at how passkey wallets compare to traditional wallets:

Authentication

  • Traditional Wallet: 12–24 word seed phrase

  • Passkey Wallet: A quick tap with your fingerprint or face

Storage

  • Traditional Wallet: Seed phrase must be written down and stored securely

  • Passkey Wallet: Cryptographic keys are stored in the device’s Secure Enclave

Security

  • Traditional Wallet: Seed phrases can be lost, stolen, or copied

  • Passkey Wallet: Access is bound to a cryptographic key protected by device-level biometrics or system authentication.

Device Sync

  • Traditional Wallet: Same seed phrase must be manually imported on every device

  • Passkey Wallet: Securely synced across devices via iCloud or Google

Setup Time

  • Traditional Wallet: 5+ minutes of setup and memorization

  • Passkey Wallet: Ready in under 30 seconds

In short: Passkeys make wallet onboarding fast, secure, and intuitive — bringing Web2-level UX to Web3 applications without compromising on security.

Having covered why passkeys offer a better alternative to traditional wallets, we’ll now move on to the implementation and integrate passkey authentication step by step.

Prerequisites

Before starting this tutorial, ensure you have:

  • Completed the Installation Guide
  • LazorKitProvider wrapping your app
  • Polyfills configured correctly
  • Deep linking scheme configured in app.json

Go to lazorkit's doc to get all these done.

Having met these prerequisites, we can now safely begin integration.

Step 1: Setup the Provider

First, ensure your root layout has the LazorKitProvider:

// app/_layout.tsx
import "../polyfills"; // ⚠️ MUST be first!

import { LazorKitProvider } from "@lazorkit/wallet-mobile-adapter";
import { Stack } from "expo-router";
import * as WebBrowser from "expo-web-browser";

// Required for completing the auth session
WebBrowser.maybeCompleteAuthSession();

const LAZORKIT_CONFIG = {
  rpcUrl: "https://api.devnet.solana.com",
  portalUrl: "https://portal.lazor.sh",
  configPaymaster: {
    paymasterUrl: "https://kora.devnet.lazorkit.com",
  },
};

export default function RootLayout() {
  return (
    <LazorKitProvider
      rpcUrl={LAZORKIT_CONFIG.rpcUrl}
      portalUrl={LAZORKIT_CONFIG.portalUrl}
      configPaymaster={LAZORKIT_CONFIG.configPaymaster}
      isDebug={true}
    >
      <Stack>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      </Stack>
    </LazorKitProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Listing 1-1: Root layout configuration with LazorKitProvider

This code sets up the foundation for passkey authentication in React Native. Let's break it down, line by line:

import "../polyfills"; // ⚠️ MUST be first!
Enter fullscreen mode Exit fullscreen mode

The polyfills import must come before anything else. Solana's web3.js library expects Node.js APIs like Buffer and crypto that don't exist in React Native. Our polyfill file provides these implementations.

import * as WebBrowser from "expo-web-browser";
WebBrowser.maybeCompleteAuthSession();
Enter fullscreen mode Exit fullscreen mode

When users authenticate with their passkey, they're redirected to LazorKit's portal in a web browser. After authentication, the browser redirects back to your app via deep link. maybeCompleteAuthSession() handles this return—it closes the browser and resumes your app with the authentication result.

const LAZORKIT_CONFIG = {
  rpcUrl: "https://api.devnet.solana.com",
  portalUrl: "https://portal.lazor.sh",
  configPaymaster: {
    paymasterUrl: "https://kora.devnet.lazorkit.com",
  },
};
Enter fullscreen mode Exit fullscreen mode

The configuration object specifies three essential endpoints:

  • rpcUrl: Where to send Solana RPC requests (we use Devnet for testing)
  • portalUrl: LazorKit's authentication portal
  • paymasterUrl: The service that sponsors gas fees for gasless transactions
isDebug={true}
Enter fullscreen mode Exit fullscreen mode

The isDebug flag enables verbose logging during development. You'll see authentication flow details and transaction information in the console. Set this to false in production.

Why maybeCompleteAuthSession()?

When the user returns from the LazorKit portal (browser), Expo needs to know the auth session is complete. This function handles that cleanup.


Step 2: Create the Connection Screen

Create a new screen for wallet connection:

// app/(tabs)/index.tsx
import { useWallet } from "@lazorkit/wallet-mobile-adapter";
import { useState } from "react";
import {
  ActivityIndicator,
  Alert,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from "react-native";

export default function WalletScreen() {
  const {
    connect, // Function to initiate connection
    disconnect, // Function to disconnect
    isConnected, // Boolean: is wallet connected?
    smartWalletPubkey, // PublicKey of the smart wallet
    isConnecting, // Boolean: is connection in progress?
  } = useWallet();

  const [isLoading, setIsLoading] = useState(false);

  // We'll implement these next...
  const handleConnect = async () => {
    /* ... */
  };
  const handleDisconnect = async () => {
    /* ... */
  };

  return <View style={styles.container}>{/* UI will go here */}</View>;
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0a0a0a",
    padding: 20,
  },
});
Enter fullscreen mode Exit fullscreen mode

Listing 1-2: Basic wallet screen structure with useWallet hook

This code creates the foundation for wallet connection. Let's examine the key parts:

const { connect, disconnect, isConnected, smartWalletPubkey, isConnecting } =
  useWallet();
Enter fullscreen mode Exit fullscreen mode

The useWallet hook provides everything needed for wallet management:

  • connect: Opens the authentication flow in the browser
  • disconnect: Clears the wallet session
  • isConnected: Whether we have an active wallet session
  • smartWalletPubkey: A Solana PublicKey object representing your wallet address
  • isConnecting: True while the browser flow is in progress
const [isLoading, setIsLoading] = useState(false);
Enter fullscreen mode Exit fullscreen mode

We maintain our own loading state in addition to isConnecting. This gives us finer control—isConnecting covers the browser flow, while isLoading can cover post-connection setup like fetching balances.

The useWallet Hook Returns

Property Type Description
connect function Initiates passkey authentication
disconnect function Clears the wallet session
isConnected boolean Whether a wallet is connected
smartWalletPubkey `PublicKey \ null`
isConnecting boolean Loading state during connection
signMessage function Signs arbitrary messages
signAndSendTransaction function Signs and broadcasts transactions

Step 3: Implement Connect Function

Now implement the connection logic:

import { getRedirectUrl } from "@/utils/redirect-url";

const handleConnect = async () => {
  // Prevent multiple connection attempts
  if (isConnecting || isLoading) return;

  try {
    setIsLoading(true);

    await connect({
      // redirectUrl tells LazorKit where to return after auth
      // This MUST match your app's URL scheme
      redirectUrl: getRedirectUrl(),

      // Called when authentication succeeds
      onSuccess: (wallet) => {
        console.log("✅ Connected successfully!");
        console.log("Smart Wallet:", wallet.smartWallet);
        console.log("Credential ID:", wallet.credentialId);
        console.log("Platform:", wallet.platform);
        setIsLoading(false);
      },

      // Called when authentication fails
      onFail: (error) => {
        console.error("❌ Connection failed:", error);
        Alert.alert(
          "Connection Failed",
          error?.message || "Unable to connect wallet"
        );
        setIsLoading(false);
      },
    });
  } catch (error: any) {
    console.error("Error during connect:", error);
    Alert.alert("Error", error?.message || "Failed to connect");
    setIsLoading(false);
  }
};
Enter fullscreen mode Exit fullscreen mode

Listing 1-3: The handleConnect function with callbacks

This function orchestrates the entire connection flow. Let's examine each part:

if (isConnecting || isLoading) return;
Enter fullscreen mode Exit fullscreen mode

Guard against double-taps. If authentication is already in progress, we ignore additional presses. This prevents confusing race conditions.

await connect({
  redirectUrl: getRedirectUrl(),
  // ...
});
Enter fullscreen mode Exit fullscreen mode

The redirectUrl is crucial for mobile apps. After the user authenticates in the browser, this URL tells the browser how to return to your app. It must match the URL scheme configured in your app.json (e.g., passpay://).

onSuccess: (wallet) => {
  console.log("Smart Wallet:", wallet.smartWallet);
  console.log("Credential ID:", wallet.credentialId);
  console.log("Platform:", wallet.platform);
  setIsLoading(false);
},
Enter fullscreen mode Exit fullscreen mode

The onSuccess callback receives a WalletInfo object with the wallet's details. The smartWallet address is what you'll use for receiving funds and displaying to users. The same passkey always produces the same wallet address.

onFail: (error) => {
  Alert.alert("Connection Failed", error?.message || "Unable to connect wallet");
  setIsLoading(false);
},
Enter fullscreen mode Exit fullscreen mode

The onFail callback handles authentication failures—user cancelled, network issues, or browser problems. We show a native alert for immediate feedback.

The connect Options

interface ConnectOptions {
  // REQUIRED: Deep link URL to return to your app
  redirectUrl: string;

  // OPTIONAL: Called with wallet info on success
  onSuccess?: (wallet: WalletInfo) => void;

  // OPTIONAL: Called with error on failure
  onFail?: (error: Error) => void;
}
Enter fullscreen mode Exit fullscreen mode

The WalletInfo Object

When connection succeeds, you receive:

interface WalletInfo {
  // Unique WebAuthn credential ID (Base64)
  credentialId: string;

  // Raw public key bytes of the passkey
  passkeyPubkey: number[];

  // YOUR SOLANA WALLET ADDRESS (Base58)
  // Use this to receive funds and display to users
  smartWallet: string;

  // Internal PDA for device management
  walletDevice: string;

  // Origin platform ('android' | 'ios')
  platform: string;
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Display Wallet Information

Show the connected wallet state:

return (
  <View style={styles.container}>
    <Text style={styles.title}>PassPay Wallet</Text>

    {isConnected && smartWalletPubkey ? (
      // ✅ CONNECTED STATE
      <View style={styles.walletCard}>
        <Text style={styles.label}>Your Wallet Address</Text>
        <Text style={styles.address} numberOfLines={1} ellipsizeMode="middle">
          {smartWalletPubkey.toBase58()}
        </Text>
        <Text style={styles.successBadge}> Connected with Passkey</Text>

        <TouchableOpacity
          style={styles.disconnectButton}
          onPress={handleDisconnect}
        >
          <Text style={styles.disconnectText}>Disconnect</Text>
        </TouchableOpacity>
      </View>
    ) : (
      // ❌ NOT CONNECTED STATE
      <View style={styles.connectContainer}>
        <Text style={styles.description}>
          Create or connect your wallet using biometric authentication (FaceID,
          TouchID, or fingerprint)
        </Text>

        <TouchableOpacity
          style={[
            styles.connectButton,
            (isConnecting || isLoading) && styles.buttonDisabled,
          ]}
          onPress={handleConnect}
          disabled={isConnecting || isLoading}
        >
          {isConnecting || isLoading ? (
            <ActivityIndicator color="white" />
          ) : (
            <Text style={styles.connectButtonText}>
              Connect with Passkey 🔐
            </Text>
          )}
        </TouchableOpacity>
      </View>
    )}
  </View>
);
Enter fullscreen mode Exit fullscreen mode

Display Address Helpers

Create a utility to truncate long addresses:

// utils/helpers.ts
export function truncateAddress(
  address: string,
  startChars: number = 4,
  endChars: number = 4
): string {
  if (!address) return "";
  if (address.length <= startChars + endChars) return address;
  return `${address.slice(0, startChars)}...${address.slice(-endChars)}`;
}

// Usage:
// truncateAddress("4UjfJZ8K1234567890abcdefghijklmnopqrstuvwxyz")
// Returns: "4Ujf...wxyz"
Enter fullscreen mode Exit fullscreen mode

Step 5: Handle Disconnect

Implement the disconnect function:

const handleDisconnect = async () => {
  try {
    await disconnect({
      onSuccess: () => {
        console.log("👋 Disconnected successfully");
      },
      onFail: (error) => {
        console.error("Disconnect failed:", error);
        Alert.alert("Error", "Failed to disconnect");
      },
    });
  } catch (error: any) {
    console.error("Error during disconnect:", error);
    Alert.alert("Error", error?.message || "Failed to disconnect");
  }
};
Enter fullscreen mode Exit fullscreen mode

What Disconnect Does

  • Clears the local wallet session
  • Resets isConnected to false
  • Resets smartWalletPubkey to null
  • Does NOT delete the passkey (user can reconnect later)

Complete Code Example

Here's the full implementation from PassPay:

// app/(tabs)/index.tsx
import { useWallet } from "@lazorkit/wallet-mobile-adapter";
import { useState } from "react";
import {
  ActivityIndicator,
  Alert,
  ScrollView,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from "react-native";
import { getRedirectUrl } from "@/utils/redirect-url";

export default function HomeScreen() {
  const { connect, isConnected, smartWalletPubkey, disconnect, isConnecting } =
    useWallet();
  const [isLoading, setIsLoading] = useState(false);

  const handleConnect = async () => {
    if (isConnecting || isLoading) return;

    try {
      setIsLoading(true);
      await connect({
        redirectUrl: getRedirectUrl(),
        onSuccess: (wallet) => {
          console.log("Connected:", wallet.smartWallet);
          setIsLoading(false);
        },
        onFail: (error) => {
          console.error("Connection failed:", error);
          Alert.alert("Connection Failed", error?.message || "Unknown error");
          setIsLoading(false);
        },
      });
    } catch (error: any) {
      console.error("Error connecting:", error);
      Alert.alert("Error", error?.message || "Failed to connect");
      setIsLoading(false);
    }
  };

  const handleDisconnect = async () => {
    try {
      await disconnect({
        onSuccess: () => console.log("Disconnected"),
        onFail: (error) => {
          console.error("Disconnect failed:", error);
          Alert.alert("Error", "Failed to disconnect");
        },
      });
    } catch (error: any) {
      Alert.alert("Error", error?.message || "Failed to disconnect");
    }
  };

  return (
    <ScrollView style={styles.container}>
      <View style={styles.content}>
        <Text style={styles.title}>PassPay</Text>
        <Text style={styles.subtitle}>Passkey-Powered Solana Wallet</Text>

        {isConnected && smartWalletPubkey ? (
          <View style={styles.walletContainer}>
            <View style={styles.walletCard}>
              <Text style={styles.label}>Wallet Address</Text>
              <Text
                style={styles.address}
                numberOfLines={1}
                ellipsizeMode="middle"
              >
                {smartWalletPubkey.toBase58()}
              </Text>
              <Text style={styles.successText}> Connected with Passkey</Text>
            </View>

            <TouchableOpacity
              style={styles.disconnectButton}
              onPress={handleDisconnect}
            >
              <Text style={styles.disconnectText}>Disconnect</Text>
            </TouchableOpacity>
          </View>
        ) : (
          <View style={styles.connectContainer}>
            <Text style={styles.description}>
              Create or connect your wallet using biometric authentication
            </Text>

            <TouchableOpacity
              style={[
                styles.connectButton,
                (isConnecting || isLoading) && styles.buttonDisabled,
              ]}
              onPress={handleConnect}
              disabled={isConnecting || isLoading}
            >
              {isConnecting || isLoading ? (
                <ActivityIndicator color="white" />
              ) : (
                <Text style={styles.connectButtonText}>
                  Connect with Passkey 🔐
                </Text>
              )}
            </TouchableOpacity>
          </View>
        )}
      </View>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0a0a0a",
  },
  content: {
    padding: 20,
    paddingTop: 60,
  },
  title: {
    fontSize: 32,
    fontWeight: "bold",
    color: "#fff",
    textAlign: "center",
  },
  subtitle: {
    fontSize: 16,
    color: "#888",
    textAlign: "center",
    marginTop: 8,
    marginBottom: 32,
  },
  walletContainer: {
    gap: 16,
  },
  walletCard: {
    backgroundColor: "#1a1a1a",
    borderRadius: 16,
    padding: 20,
    borderWidth: 1,
    borderColor: "#333",
  },
  label: {
    fontSize: 12,
    color: "#888",
    marginBottom: 8,
    textTransform: "uppercase",
  },
  address: {
    fontSize: 14,
    color: "#fff",
    fontFamily: "monospace",
  },
  successText: {
    color: "#14F195",
    marginTop: 12,
    fontSize: 14,
  },
  connectContainer: {
    alignItems: "center",
    gap: 24,
  },
  description: {
    fontSize: 16,
    color: "#888",
    textAlign: "center",
    lineHeight: 24,
  },
  connectButton: {
    backgroundColor: "#9945FF",
    paddingVertical: 16,
    paddingHorizontal: 32,
    borderRadius: 12,
    width: "100%",
    alignItems: "center",
  },
  buttonDisabled: {
    opacity: 0.6,
  },
  connectButtonText: {
    color: "#fff",
    fontSize: 18,
    fontWeight: "600",
  },
  disconnectButton: {
    backgroundColor: "transparent",
    borderWidth: 1,
    borderColor: "#ff4444",
    paddingVertical: 12,
    borderRadius: 12,
    alignItems: "center",
  },
  disconnectText: {
    color: "#ff4444",
    fontSize: 16,
    fontWeight: "500",
  },
});
Enter fullscreen mode Exit fullscreen mode

How It Works Under the Hood

1. Passkey Creation (First Time User)

User taps "Connect"
    ↓
Browser opens LazorKit Portal
    ↓
Portal calls navigator.credentials.create()
    ↓
Device shows biometric prompt
    ↓
Secure Enclave generates keypair
    ↓
Public key sent to LazorKit
    ↓
Smart Wallet PDA created on-chain
    ↓
Redirect back to app with wallet info
Enter fullscreen mode Exit fullscreen mode

2. Passkey Authentication (Returning User)

User taps "Connect"
    ↓
Browser opens LazorKit Portal
    ↓
Portal calls navigator.credentials.get()
    ↓
Device shows biometric prompt
    ↓
Secure Enclave signs challenge
    ↓
Signature verified
    ↓
Redirect back to app with wallet info
Enter fullscreen mode Exit fullscreen mode

This flow demonstrates how LazorKit bridges Web2 authentication and Web3 ownership—allowing developers to ship secure, non-custodial wallets without exposing users to seed phrases or browser extensions.

⚠️ Note: While passkeys remove the need for seed phrases, users should understand device recovery and platform sync implications (iCloud / Google Password Manager).

Next Steps

Explore More LazorKit examples...

This tutorial covered passkey wallet authentication—the foundation of LazorKit integration. But there's much more you can build. If you’d like to go deeper, I’ve put together PassPay, a reference repository that demonstrates LazorKit integrations across both Web (Next.js) and Mobile (React Native) with real, production-style examples. Some of these examples are:

  • Gasless Transactions - Send SOL without paying fees
  • On-Chain Memos - Write permanent blockchain messages
  • Native SOL Staking - Multi-instruction transactions with passkeys
  • Subscription Payments - Recurring payment flows
  • Session Management - Keep users logged in across sessions

📚 View the complete documentation: PassPay on GitHub includes 11 step-by-step tutorials for both Web (Next.js) and Mobile (React Native) with full working code.

Top comments (0)