Time to complete: 15-20 minutes
Over 20% 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 / Next.js
Anyone curious about replacing seed phrases with passkeys
Builders exploring gasless Solana UX with LazorKit
What are Passkeys?
Passkeys are a…
Time to complete: 15-20 minutes
Over 20% 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 / Next.js
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 your biometrics — only you can authorize actions
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 for your nextjs environment
- A modern browser
- Running HTTPS on
localhost(WebAuthn requirement)
Having met these prerequisites, we can now safely begin integration.
Step 1: Setup the Provider
First, ensure your root layout has the LazorkitProvider:
// app/providers.tsx
"use client";
import React, { useEffect } from "react";
import { LazorkitProvider } from "@lazorkit/wallet";
import { Buffer } from "buffer";
import { Toaster } from "react-hot-toast";
const LAZORKIT_CONFIG = {
rpcUrl: "https://api.devnet.solana.com",
portalUrl: "https://portal.lazor.sh",
paymasterConfig: {
paymasterUrl: "https://kora.devnet.lazorkit.com",
},
};
export function AppProviders({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Buffer polyfill for browser
if (typeof window !== "undefined" && !window.Buffer) {
window.Buffer = Buffer;
}
}, []);
return (
<LazorkitProvider
rpcUrl={LAZORKIT_CONFIG.rpcUrl}
portalUrl={LAZORKIT_CONFIG.portalUrl}
paymasterConfig={LAZORKIT_CONFIG.paymasterConfig}
>
{children}
<Toaster position="top-right" />
</LazorkitProvider>
);
}
Listing 1-1: Setting up the LazorkitProvider with configuration
This code sets up the foundation for passkey authentication. Let’s break it down line by line:
The "use client" directive at the top tells Next.js this is a client component. This is necessary because LazorKit uses browser APIs like WebAuthn that don’t exist on the server. Next are the imports. One is of particular concern here:
import { LazorkitProvider } from "@lazorkit/wallet";
We import LazorkitProvider, which is a React context provider that makes wallet functionality available throughout your app. Any component that needs wallet access must be wrapped by this provider.
const LAZORKIT_CONFIG = {
rpcUrl: "https://api.devnet.solana.com",
portalUrl: "https://portal.lazor.sh",
paymasterConfig: {
paymasterUrl: "https://kora.devnet.lazorkit.com",
},
};
The configuration object contains three essential URLs:
rpcUrl: The Solana RPC endpoint for blockchain communication (we use Devnet for testing in this case)portalUrl: LazorKit’s authentication portal where passkey ceremonies happenpaymasterUrl: The service that sponsors gas fees for gasless transactions.
Moving on to the next line, we have:
useEffect(() => {
if (typeof window !== "undefined" && !window.Buffer) {
window.Buffer = Buffer;
}
}, []);
This useEffect hook adds a Buffer polyfill to the browser’s window object. Solana’s web3.js library expects Node.js’s Buffer class, which browsers don’t have natively. In other words, we need to add this buffer class. We then check for window first to avoid errors during server-side rendering. Moving on, we need to have our app with the AppProviders as Listing 1-2 illustrates.
Wrap Your App
In your layout.tsx, do this:
// app/layout.tsx
import { AppProviders } from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AppProviders>{children}</AppProviders>
</body>
</html>
);
}
Listing 1-2: Wrapping your application with AppProviders
This is the root layout that wraps your entire Next.js application. The key line is:
<AppProviders>{children}</AppProviders>
By wrapping {children} with AppProviders, every page and component in your app gains access to the wallet context via the useWallet hook. Without this wrapper, calling useWallet() would throw an error.
You can find more information about this setup on the lazorkit’s docs
Step 2: Create the Login Page
Now we create a login page for wallet connection:
// app/(auth)/login/page.tsx
"use client";
import { useWallet } from "@lazorkit/wallet";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
export default function LoginPage() {
const router = useRouter();
const {
connect, // Function to initiate connection
isConnected, // Boolean: is wallet connected?
isConnecting, // Boolean: is connection in progress?
wallet, // Wallet info (smartWallet address)
} = useWallet();
const [error, setError] = useState<string | null>(null);
// Redirect to dashboard if already connected
useEffect(() => {
if (isConnected && wallet?.smartWallet) {
router.push("/transfer"); // transfer is a page I decided to use here. Be at liberty to use any of your choosing
}
}, [isConnected, wallet, router]);
// We'll implement this next...
const handleConnect = async () => {
/* ... */
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#0a0a0a]">
<div className="max-w-md w-full p-8">
<h1 className="text-3xl font-bold text-white text-center mb-8">
Welcome to PassPay
</h1>
{/* Connection button will go here */}
</div>
</div>
);
}
Listing 1-3: Basic login page structure with useWallet hook
This code creates the foundation for our login page. Let’s examine the key parts:
const { connect, isConnected, isConnecting, wallet } = useWallet();
The useWallet hook is the primary interface to LazorKit. We destructure four essential properties:
connect: An async function that triggers the passkey authentication flowisConnected: A boolean that tells us if a wallet session existsisConnecting: A boolean that’strueduring the authentication processwallet: An object containing the connected wallet’ssmartWalletaddress
Let’s observe the next line, shall we?
useEffect(() => {
if (isConnected && wallet?.smartWallet) {
router.push("/transfer");
}
}, [isConnected, wallet, router]);
This effect runs whenever connection state changes. If the user is already connected (perhaps from a previous session stored in the browser), we automatically redirect them to the main app. The optional chaining (wallet?.smartWallet) safely handles cases where wallet might be null.
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 |
wallet | { smartWallet: string } | Wallet address info |
smartWalletPubkey | `PublicKey \ | null` |
isConnecting | boolean | Loading state during connection |
signAndSendTransaction | function | Signs and broadcasts transactions |
Step 3: Implement Connect Function
Now, it is time to add the connection logic:
const handleConnect = async () => {
setError(null);
try {
// Connect with paymaster mode for gasless transactions
const info = await connect({ feeMode: "paymaster" });
if (info?.credentialId) {
// Optionally store credential for later use
console.log("Credential ID:", info.credentialId);
}
toast.success("Wallet connected! 🎉");
router.push("/transfer");
} catch (e: unknown) {
const err = e as Error;
const msg = err?.message || "Connection failed";
setError(msg);
// User-friendly error messages
if (msg.includes("NotAllowedError")) {
toast.error("You cancelled the passkey prompt.");
} else if (msg.includes("PublicKeyCredential")) {
toast.error("Your browser does not support passkeys.");
} else {
toast.error("Login failed. Please try again.");
}
}
};
Listing 1-4: The handleConnect function that initiates passkey authentication
This function handles the entire connection flow. Let’s walk through it:
const info = await connect({ feeMode: "paymaster" });
The connect function opens LazorKit’s portal in the browser, triggering the WebAuthn ceremony. The feeMode: "paymaster" option tells LazorKit that future transactions should be gasless, meaning the paymaster will sponsor fees. This returns a WalletInfo object containing the new wallet’s details.
if (info?.credentialId) {
console.log("Credential ID:", info.credentialId);
}
The credentialId is a unique identifier for this passkey. You might store this for analytics or to identify returning users. The same passkey always produces the same wallet address.
if (msg.includes("NotAllowedError")) {
toast.error("You cancelled the passkey prompt.");
}
Error handling is crucial for good UX. NotAllowedError means the user dismissed the biometric prompt—we show a friendly message rather than a cryptic error code.
Understanding connect() Options
There is a minor detail we should know about the feeMode:
await connect({
feeMode: "paymaster", // Gasless transactions (recommended)
// feeMode: "self", // User pays gas fees
});
| Fee Mode | Description |
|---|---|
paymaster | LazorKit sponsors transaction fees |
self | User pays fees from their SOL balance |
So, you choose... depending on what you want your app to do.
Step 4: Display Wallet Information
Build the complete login UI:
// app/(auth)/login/page.tsx
"use client";
import { useWallet } from "@lazorkit/wallet";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
export default function LoginPage() {
const router = useRouter();
const { connect, isConnected, isConnecting, wallet } = useWallet();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isConnected && wallet?.smartWallet) {
router.push("/transfer");
}
}, [isConnected, wallet, router]);
const handleConnect = async () => {
setError(null);
try {
await connect({ feeMode: "paymaster" });
toast.success("Wallet connected! 🎉");
} catch (e: unknown) {
const err = e as Error;
const msg = err?.message || "Connection failed";
setError(msg);
if (msg.includes("NotAllowedError")) {
toast.error("You cancelled the passkey prompt.");
} else if (msg.includes("PublicKeyCredential")) {
toast.error("Your browser does not support passkeys.");
} else {
toast.error("Login failed. Please try again.");
}
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#0a0a0a] p-4">
<div className="max-w-md w-full">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-white mb-2">🔐 PassPay</h1>
<p className="text-gray-400">
No seed phrases. Just your biometrics.
</p>
</div>
{/* Card */}
<div className="bg-[#1a1a1a] rounded-2xl p-8 border border-gray-800">
{/* Benefits */}
<div className="space-y-3 mb-6">
<div className="flex items-center gap-3 text-gray-300">
<span className="text-[#14F195]">✓</span>
<span>No passwords or seed phrases</span>
</div>
<div className="flex items-center gap-3 text-gray-300">
<span className="text-[#14F195]">✓</span>
<span>Hardware-level security</span>
</div>
<div className="flex items-center gap-3 text-gray-300">
<span className="text-[#14F195]">✓</span>
<span>Syncs across your devices</span>
</div>
</div>
{/* Connect Button */}
<button
onClick={handleConnect}
disabled={isConnecting}
className="w-full py-4 px-6 bg-[#9945FF] hover:bg-[#8035E0]
disabled:opacity-50 disabled:cursor-not-allowed
text-white font-semibold rounded-xl transition-colors"
>
{isConnecting ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Connecting...
</span>
) : (
"✨ Continue with Passkey"
)}
</button>
{/* Footer */}
<p className="text-xs text-gray-500 text-center mt-4">
Powered by LazorKit • Your device is your wallet
</p>
{/* Error Display */}
{error && (
<p className="mt-4 text-sm text-red-400 text-center">{error}</p>
)}
{/* Success State */}
{wallet?.smartWallet && (
<div className="mt-4 p-4 rounded-lg bg-[#14F195]/10 border border-[#14F195]/20">
<p className="text-sm text-[#14F195] font-semibold">
✓ Wallet Created!
</p>
<p className="text-xs text-gray-400 mt-1 font-mono break-all">
{wallet.smartWallet}
</p>
</div>
)}
</div>
</div>
</div>
);
}
How It Works Under the Hood
The WebAuthn Flow
User clicks "Connect"
- Your app calls
connect({ feeMode: "paymaster" })
LazorKit Portal opens
- Browser triggers WebAuthn ceremony
- User sees biometric prompt and proceeds with it
Passkey created/retrieved
- Credential stored in Secure Enclave
- Syncs via platform (iCloud/Google)
Smart wallet derived
- PDA derived from credential
- Same passkey = same wallet address
Connection complete
wallet.smartWalletcontains address- Ready for transactions
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.
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.