Quick Start
This guide walks you through setting up ursalock in a React application with passkey-based E2EE.
Installation
Section titled “Installation”npm install @ursalock/zustand @ursalock/client @ursalock/cryptoHow It Works
Section titled “How It Works”- User authenticates with a passkey (WebAuthn)
- The passkey derives a cipherJwk using WebAuthn PRF extension
- Your Zustand store is encrypted with this key
- Data syncs to the server (encrypted) and across devices
Same passkey = same encryption key = same data on any device.
1. Setup the Auth Client
Section titled “1. Setup the Auth Client”import { VaultClient } from "@ursalock/client";
export const vaultClient = new VaultClient({ serverUrl: "https://vault.example.com",});2. Create an Encrypted Store
Section titled “2. Create an Encrypted Store”import { create, type StateCreator } from "zustand";import { vault, type VaultOptionsJwk } from "@ursalock/zustand";import type { CipherJWK } from "@ursalock/crypto";import { vaultClient } from "../lib/vault-client";
interface NotesState { notes: string[]; addNote: (note: string) => void;}
// Store factory - needs cipherJwk from authenticationexport function createNotesStore(cipherJwk: CipherJWK) { const storeCreator: StateCreator<NotesState> = (set) => ({ notes: [], addNote: (note) => set((s) => ({ notes: [...s.notes, note] })), });
const options: VaultOptionsJwk<NotesState> = { name: "my-notes", cipherJwk, server: "https://vault.example.com", getToken: () => { const header = vaultClient.getAuthHeader(); return header["Authorization"]?.replace("Bearer ", "") ?? null; }, syncInterval: 30000, // Auto-sync every 30s };
return create(vault(storeCreator, options));}
// Store instance - initialized after authlet notesStore: ReturnType<typeof createNotesStore> | null = null;
export function initNotesStore(cipherJwk: CipherJWK) { notesStore = createNotesStore(cipherJwk); return notesStore;}
export function useNotes<T>(selector: (state: NotesState) => T): T { if (!notesStore) throw new Error("Store not initialized"); return notesStore(selector);}3. Add Authentication
Section titled “3. Add Authentication”import { useState } from "react";import { useSignUp, useSignIn, type ZKCredential } from "@ursalock/client";import { vaultClient } from "../lib/vault-client";
interface AuthProps { onAuthenticated: (credential: ZKCredential) => void;}
export function Auth({ onAuthenticated }: AuthProps) { const [mode, setMode] = useState<"signin" | "signup">("signin"); const { signUp, isLoading: isSigningUp } = useSignUp(vaultClient); const { signIn, isLoading: isSigningIn } = useSignIn(vaultClient);
const handleAuth = async () => { const fn = mode === "signup" ? signUp : signIn; const result = await fn({ usePasskey: true });
if (result.success && result.credential) { onAuthenticated(result.credential); } };
return ( <div> <button onClick={handleAuth} disabled={isSigningUp || isSigningIn}> {mode === "signup" ? "Create Account" : "Sign In"} with Passkey </button> <button onClick={() => setMode(mode === "signin" ? "signup" : "signin")}> {mode === "signin" ? "Need an account?" : "Already have one?"} </button> </div> );}4. Wire It Up
Section titled “4. Wire It Up”import { useState } from "react";import type { ZKCredential } from "@ursalock/client";import { Auth } from "./components/Auth";import { Notes } from "./components/Notes";import { initNotesStore } from "./stores/notes";
export function App() { const [isAuthenticated, setIsAuthenticated] = useState(false);
const handleAuthenticated = (credential: ZKCredential) => { // Initialize the encrypted store with the derived key initNotesStore(credential.cipherJwk); setIsAuthenticated(true); };
if (!isAuthenticated) { return <Auth onAuthenticated={handleAuthenticated} />; }
return <Notes />;}5. Use the Store
Section titled “5. Use the Store”import { useState } from "react";import { useNotes } from "../stores/notes";
export function Notes() { const notes = useNotes((s) => s.notes); const addNote = useNotes((s) => s.addNote); const [input, setInput] = useState("");
return ( <div> <ul> {notes.map((note, i) => ( <li key={i}>{note}</li> ))} </ul> <input value={input} onChange={(e) => setInput(e.target.value)} /> <button onClick={() => { addNote(input); setInput(""); }}>Add</button> </div> );}Key Concepts
Section titled “Key Concepts”Passkey = Encryption Key
Section titled “Passkey = Encryption Key”The cipherJwk is derived from your passkey using the WebAuthn PRF extension. This means:
- No recovery key to store — your passkey IS the key
- Same passkey = same data — works across devices with synced passkeys
- Zero-knowledge — server never sees your plaintext data
Re-authentication on Refresh
Section titled “Re-authentication on Refresh”After a page refresh, the cipherJwk is lost (it lives only in memory for security). The user must re-authenticate with their passkey to derive the key again.
// Detect if we need to re-authconst jwt = vaultClient.getToken(); // JWT persists in localStorageconst hasCipherKey = Boolean(cipherJwk); // But cipherJwk doesn't
if (jwt && !hasCipherKey) { // Valid session but no key = need to re-auth with passkey showAuthScreen();}Sync Status
Section titled “Sync Status”// Check sync statusconst status = useStore.vault.getSyncStatus();// "idle" | "syncing" | "synced" | "error" | "offline"
// Manual syncawait useStore.vault.sync();Local-Only Mode
Section titled “Local-Only Mode”Don’t need cloud sync? Skip the server config:
const options: VaultOptionsJwk<NotesState> = { name: "my-notes", cipherJwk, // No server = encrypted localStorage only};Next Steps
Section titled “Next Steps”- Authentication — Passkey flows, hooks, error handling
- Syncing — Conflict resolution, offline support
- Self-Hosting — Deploy your own server