Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Web3

How to Implement Client-Side Cryptographic Wallet Key Management in Web3 Applications

Master web3 wallet key management by securing private keys in the browser. Implement Web Crypto API and sandbox strategies to protect user assets.

Dian Rijal Asyrof/August 14, 2026/6 min read
Illustration for How to Implement Client-Side Cryptographic Wallet Key Management in Web3 Applications

Web3 applications often need to manage private keys directly in the browser. Think of non-custodial web wallets, temporary session keys for Web3 games, or local account abstraction signers. If you build these tools, you face a hard truth. Browsers are hostile environments. A single malicious dependency or a cross-site scripting (XSS) vulnerability can drain your users' funds in seconds.

You cannot rely on simple storage mechanisms to keep secrets safe. Storing raw private keys or mnemonic seed phrases in plain text is a recipe for disaster. To build a secure client-side wallet, you must combine the Web Crypto API, secure key derivation, sandboxing, and memory management.

Here is how to design and implement a secure client-side key management system.

The Threat Model in the Browser

To secure keys, you must understand how they get stolen. The primary threat in a web application is cross-site scripting. If an attacker can execute arbitrary JavaScript in your application, they can access the global window object, read application memory, and inspect storage.

Many developers default to storing keys in localStorage or sessionStorage. This is a bad idea. Any script running on your origin can read localStorage. If you use third-party analytics, chat widgets, or utility libraries, a compromise in any of those packages gives the attacker direct access to your users' raw keys.

Storing keys in memory as plain text strings is also risky. JavaScript strings are immutable. When you create a string containing a private key, that string remains in the browser heap until the garbage collector decides to reclaim it. An attacker who gains access to memory through a vulnerability can dump the heap and extract the keys.

To protect secrets, you must ensure that:

  1. Raw keys are never stored in plain text on disk.
  2. Raw keys remain in memory for the shortest time possible.
  3. Cryptographic operations are isolated from the main application logic.
  4. The application limits where data can be sent using network policies.

Deriving Keys Securely with PBKDF2

You should never store a user's private key directly. Instead, encrypt the key with a key derived from a user-defined password. When the user wants to sign a transaction, they enter their password, the app derives the encryption key, decrypts the private key, signs the transaction, and discards the decrypted key.

The Web Crypto API provides native support for PBKDF2 (Password-Based Key Derivation Function 2). This function runs a hash algorithm thousands of times to turn a weak password into a strong cryptographic key. This process makes brute-force attacks computationally expensive.

Here is how to derive an encryption key from a password and a salt:

async function deriveEncryptionKey(password, salt) {
  const encoder = new TextEncoder();
  const passwordBuffer = encoder.encode(password);
 
  // Import the raw password as a key material
  const keyMaterial = await window.crypto.subtle.importKey(
    "raw",
    passwordBuffer,
    "PBKDF2",
    false,
    ["deriveKey"]
  );
 
  // Derive the AES-GCM key
  return window.crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: salt,
      iterations: 100000,
      hash: "SHA-256"
    },
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

The salt must be unique for every user. Generate the salt using a cryptographically secure pseudorandom number generator (CSPRNG), which is available via window.crypto.getRandomValues. Store the salt alongside the encrypted private key. You do not need to keep the salt secret, but it must be unique to prevent attackers from using precomputed tables (rainbow tables) to crack passwords.

Encrypting the Secret with AES-GCM

Once you have derived the encryption key, use AES-GCM (Advanced Encryption Standard with Galois/Counter Mode) to encrypt the private key or mnemonic phrase. AES-GCM provides both confidentiality and integrity. It ensures that an attacker cannot read the secret or tamper with the encrypted payload without detection.

When encrypting, you must generate a unique initialization vector (IV) for every encryption operation. Never reuse an IV with the same key.

Here is the implementation for encrypting a mnemonic phrase:

async function encryptSecret(secretText, password) {
  const encoder = new TextEncoder();
  const rawSecret = encoder.encode(secretText);
  
  // Generate a random 16-byte salt and 12-byte IV
  const salt = window.crypto.getRandomValues(new Uint8Array(16));
  const iv = window.crypto.getRandomValues(new Uint8Array(12));
  
  const encryptionKey = await deriveEncryptionKey(password, salt);
  
  const encryptedBuffer = await window.crypto.subtle.encrypt(
    {
      name: "AES-GCM",
      iv: iv
    },
    encryptionKey,
    rawSecret
  );
  
  return {
    ciphertext: new Uint8Array(encryptedBuffer),
    salt: salt,
    iv: iv
  };
}

After encryption, store the ciphertext, salt, and iv in IndexedDB. IndexedDB is better than localStorage because it supports binary data natively. You do not need to convert your byte arrays to base64 strings, which saves processing time and reduces memory fragmentation.

To decrypt the secret, reverse the process:

async function decryptSecret(encryptedData, password) {
  const { ciphertext, salt, iv } = encryptedData;
  const decryptionKey = await deriveEncryptionKey(password, salt);
  
  try {
    const decryptedBuffer = await window.crypto.subtle.decrypt(
      {
        name: "AES-GCM",
        iv: iv
      },
      decryptionKey,
      ciphertext
    );
    
    return new Uint8Array(decryptedBuffer);
  } catch (error) {
    throw new Error("Decryption failed. Incorrect password or corrupted data.");
  }
}

Managing Memory and Preventing Leaks

JavaScript is a garbage-collected language. You cannot tell the runtime to delete a string from memory immediately. If you convert your decrypted private key buffer into a standard JavaScript string, that string might linger in the browser's memory heap for minutes or hours.

To minimize this risk, avoid using strings for raw private keys or seed phrases. Keep the data in typed arrays like Uint8Array. Unlike strings, typed arrays allow you to modify their values directly in place. When you finish a cryptographic operation, overwrite the array with zeros.

function zeroiseArray(array) {
  array.fill(0);
}
 
// Example usage
const decryptedKey = await decryptSecret(encryptedData, password);
 
// Perform the signing operation
const signature = await signTransaction(decryptedKey, transactionData);
 
// Immediately clear the key from memory
zeroiseArray(decryptedKey);

By calling array.fill(0), you overwrite the sensitive bytes in memory. Even if an attacker dumps the heap later, they will only find zeros where the private key used to be.

Isolating Keys with Web Workers

Even if you zero out your arrays, the main thread of your application is still vulnerable. It runs the UI, handles user input, and executes third-party libraries. If an attacker injects a malicious script, they can monkey-patch the window.crypto.subtle methods to intercept keys during the brief window they are decrypted.

You can prevent this by moving all cryptographic operations to a Web Worker. Web Workers run in a separate execution context with their own memory space. They do not have access to the DOM, the global window object, or the main thread's variables.

The main thread communicates with the worker by sending messages. When the main thread needs to sign a transaction, it sends the unsigned transaction and the encrypted key payload to the worker. The worker prompts the user for their password (via a secure channel or a message), decrypts the key, signs the transaction, and returns the signature to the main thread. The main thread never sees the raw private key.

Here is a simple example of a Web Worker handling cryptographic requests:

// worker.js
self.onmessage = async (event) => {
  const { type, payload } = event.data;
  
  if (type === "SIGN_TRANSACTION") {
    const { encryptedData, password, transaction } = payload;
    
    try {
      const rawKey = await decryptSecret(encryptedData, password);
      const signature = await signWithKey(rawKey, transaction);
      
      // Clear key immediately
      zeroiseArray(rawKey);
      
      self.postMessage({ type: "SIGN_SUCCESS", signature });
    } catch (error) {
      self.postMessage({ type: "SIGN_FAILURE", error: error.message });
    }
  }
};

To send data to the worker without copying it in memory, use transferable objects. Transferable objects transfer ownership of the underlying memory buffer from the main thread to the worker. Once transferred, the buffer is no longer available on the main thread, which prevents duplicate copies of the secret from existing in memory.

The Elliptic Curve Challenge in Web Crypto

The Web Crypto API has a limitation when it comes to Web3 applications. Most Web3 networks use specific elliptic curves that are not universally supported by the Web Crypto API. For example, Ethereum and Bitcoin use secp256k1. Solana uses ed25519.

While the Web Crypto API supports curves like P-256 and P-384, browser support for secp256k1 is almost non-existent. Support for ed25519 is growing but is not yet universal across all major browsers.

Because of this limitation, you will need to use a JavaScript library for the actual elliptic curve math (such as signing transactions). Use audited, modern libraries like @noble/curves instead of older, bloated libraries. The Noble libraries are designed to be secure, side-channel resistant, and free of external dependencies.

When using a JavaScript library inside your Web Worker, the memory management rules become even more critical. Ensure that the library you choose accepts Uint8Array inputs and does not convert them to internal string representations.

Hardening the Application with Security Policies

Technical measures inside your JavaScript code are only half the battle. You must also configure the browser environment to prevent data exfiltration. If an attacker manages to exploit an XSS vulnerability and decrypts a key, they still need to send that key back to their server.

You can block this step by implementing a strict Content Security Policy (CSP). A CSP tells the browser which domains the application is allowed to communicate with.

A secure CSP for a Web3 app should limit network connections:

Content-Security-Policy: default-src 'self'; script-src 'self'; connect-src 'self' https://your-ethereum-rpc-node.com; object-src 'none'; frame-ancestors 'none';

In this policy:

  • default-src 'self' prevents the browser from loading assets or sending data to external domains by default.
  • connect-src limits network requests (like fetch or WebSockets) to your own origin and your trusted RPC node. Even if a script steals a key, it cannot send an HTTP request to attacker-server.com to exfiltrate the data.

Additionally, use the cross-origin-opener-policy (COOP) and cross-origin-embedder-policy (COEP) headers to isolate your application process from other browser tabs. This mitigates side-channel attacks like Spectre, which can read memory across different origins.

Securing keys in the browser requires defense in depth. By combining PBKDF2 key derivation, AES-GCM encryption, Web Worker isolation, memory zeroing, and strict security headers, you build a system that protects user assets even when operating in a hostile environment.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleGLM-5.2 Token Costs Optimization: Writer Upgrades Post-Training HarnessNext articleBeyond the Prompt: Hardening AI Agent Gateways Against Prompt Injection Vulnerabilities
Web3CryptographySecurity
On this page↓
  1. The Threat Model in the Browser
  2. Deriving Keys Securely with PBKDF2
  3. Encrypting the Secret with AES-GCM
  4. Managing Memory and Preventing Leaks
  5. Isolating Keys with Web Workers
  6. The Elliptic Curve Challenge in Web Crypto
  7. Hardening the Application with Security Policies

On this page

  1. The Threat Model in the Browser
  2. Deriving Keys Securely with PBKDF2
  3. Encrypting the Secret with AES-GCM
  4. Managing Memory and Preventing Leaks
  5. Isolating Keys with Web Workers
  6. The Elliptic Curve Challenge in Web Crypto
  7. Hardening the Application with Security Policies

See also

Illustration for How MEV Sandwich Attacks Work and How to Protect Your Smart Contracts
Web3/Aug 13, 2026

How MEV Sandwich Attacks Work and How to Protect Your Smart Contracts

An in-depth look at the mechanics of Maximal Extractable Value (MEV) sandwich attacks on decentralized exchanges and how developers can defend their protocols.

6 min read
Web3DeFi
Illustration for Merkle Trees Explained for Blockchain Developers
Web3/Aug 12, 2026

Merkle Trees Explained for Blockchain Developers

A practical guide to Merkle trees for blockchain developers. How they work, why they matter, and where they're used beyond crypto.

6 min read
Web3Blockchain
Illustration for Inside the $116 Million Coldcard Hack: How Offline Bitcoin Seed Phrases Were Guessed Without Touching the Devices
Web3/Aug 6, 2026

Inside the $116 Million Coldcard Hack: How Offline Bitcoin Seed Phrases Were Guessed Without Touching the Devices

How did hackers guess Coldcard seed phrases offline? Analyze the firmware vulnerability that compromised entropy and drained $116 million in bitcoin.

4 min read
BitcoinCryptography