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
-
LazorKitProviderwrapping 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>
);
}
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!
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();
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",
},
};
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}
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,
},
});
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();
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 SolanaPublicKeyobject representing your wallet address -
isConnecting: True while the browser flow is in progress
const [isLoading, setIsLoading] = useState(false);
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);
}
};
Listing 1-3: The handleConnect function with callbacks
This function orchestrates the entire connection flow. Let's examine each part:
if (isConnecting || isLoading) return;
Guard against double-taps. If authentication is already in progress, we ignore additional presses. This prevents confusing race conditions.
await connect({
redirectUrl: getRedirectUrl(),
// ...
});
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);
},
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);
},
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;
}
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;
}
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>
);
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"
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");
}
};
What Disconnect Does
- Clears the local wallet session
- Resets
isConnectedtofalse - Resets
smartWalletPubkeytonull - 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",
},
});
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
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
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)