2026-06-06 17:55:00+00:00

The architecture of local-first AI applications introduces unique security challenges. By eliminating the middle-tier server, we gain offline resilience, lower latency, and zero data telemetry. However, we also place the burden of protecting sensitive assets—like OpenAI, Gemini, and OpenRouter API keys—squarely on the client's device. Storing raw API keys in browser storage (localStorage) is a dangerous practice, leaving them vulnerable to Cross-Site Scripting (XSS) or local physical extraction.

In the latest update to Interview Copilot, we implemented a client-side Local Security Vault that encrypts all API keys using a Master Passphrase. The cryptographic workflow leverages the browser's native Web Crypto API to guarantee that plaintext keys never touch the storage layer unencrypted.


The Security Vault Architecture

The core concept is to derive a cryptographic key from a master passphrase and use it to encrypt the API keys before saving them. When the application loads, it detects the presence of the encrypted vault, presents a secure lock overlay, and requires the user to input the passphrase to decrypt the keys in memory.

Passphrase Input PBKDF2 Key Derivation 100,000 Iterations / SHA-256 Salt (16 bytes) CryptoKey (256-bit) API Key (Plaintext) AES-GCM Encryption Initialization Vector (12 bytes) 256-bit derived key Base64 Encrypted JSON { salt, iv, ciphertext } Saved to LocalStorage

Key Derivation via PBKDF2

To derive a cryptographically strong symmetric key from a user-supplied passphrase, we use the Password-Based Key Derivation Function 2 (PBKDF2). A weak passphrase can be guessed quickly; PBKDF2 slows down brute-force attacks by running a computationally expensive hashing loop:

async function deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
  const enc = new TextEncoder();
  const keyMaterial = await window.crypto.subtle.importKey(
    "raw",
    enc.encode(passphrase),
    { name: "PBKDF2" },
    false,
    ["deriveBits", "deriveKey"]
  );

  return window.crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: salt,
      iterations: 100000,
      hash: "SHA-256",
    },
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

The derived key is configured for use with the AES-GCM cipher. We utilize 100,000 iterations of SHA-256, combined with a unique 16-byte random salt, making it extremely resistant to pre-computed dictionary attacks.

Authenticated Encryption with AES-GCM

Once we have the derived key, we perform symmetric encryption using Advanced Encryption Standard in Galois/Counter Mode (AES-GCM). AES-GCM is an authenticated encryption algorithm, meaning it ensures both confidentiality and integrity of the encrypted data:

export async function encryptText(text: string, passphrase: string): Promise<string> {
  if (!text) return "";
  const salt = window.crypto.getRandomValues(new Uint8Array(16));
  const iv = window.crypto.getRandomValues(new Uint8Array(12));
  const key = await deriveKey(passphrase, salt);
  const enc = new TextEncoder();
  
  const ciphertextBuffer = await window.crypto.subtle.encrypt(
    { name: "AES-GCM", iv: iv },
    key,
    enc.encode(text)
  );

  const payload = {
    salt: arrayBufferToBase64(salt),
    iv: arrayBufferToBase64(iv),
    ciphertext: arrayBufferToBase64(ciphertextBuffer),
  };

  return JSON.stringify(payload);
}

The output of the encryption is a JSON payload containing the Base64-encoded salt, initialization vector (IV), and ciphertext. The IV (12 bytes) is generated using cryptographically secure random values (window.crypto.getRandomValues) to ensure that encrypting the same text twice yields completely different ciphertexts.

SSR-Safe Hydration in Next.js

Implementing local-first state persistence in Next.js requires careful handling of Server-Side Rendering (SSR). If we attempt to read from localStorage during the initial component render, the server-generated HTML will mismatch the client-side state, causing a React hydration mismatch (Error #418). To avoid this, we initialize all key states with empty values and defer storage read/unlock workflows to a client-only useEffect hook:

useEffect(() => {
  const hasVerify = typeof window !== "undefined" && !!localStorage.getItem("passphrase_verify");
  if (hasVerify) {
    setIsLocked(true);
  } else {
    // Populate raw keys directly if no vault is set
    setOpenaiKey(localStorage.getItem("openai_api_key") || "");
    // ... other keys
  }
}, []);

User Experience & Input Shielding

Security is only as good as the user experience surrounding it. To prevent local key leakage, we implemented key input shielding on the master passphrase field. This includes blocking standard copy, cut, and drag operations, as well as disabling the browser's context menu on the passphrase field. This prevents accidental exposure of the passphrase in clipboard managers:

const preventCopyPasteActions = {
  onCopy: (e: React.ClipboardEvent) => e.preventDefault(),
  onCut: (e: React.ClipboardEvent) => e.preventDefault(),
  onDragStart: (e: React.DragEvent) => e.preventDefault(),
  onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
  onKeyDown: (e: React.KeyboardEvent) => {
    if ((e.ctrlKey || e.metaKey) && (e.key === "c" || e.key === "x")) {
      e.preventDefault();
    }
  }
};

When the dashboard detects an encrypted vault, it displays a full-screen glassmorphism Lock Overlay UI. The user must type their passphrase, which is tested by attempting to decrypt the verification string "verified". If decryption succeeds, the vault decrypts all API keys in memory and updates the application context, hiding the overlay.

By shifting to browser-direct authenticated encryption, users can comfortably use custom API keys in Web-based AI tools without relying on third-party backend servers, preserving full data sovereignty.