codelessgenie blog

Where Does WebCrypto API Store Keys? How to Check Existing Keypairs & Retrieve Private Keys in Browser (Node.js/JS Guide)

The Web Crypto API is a powerful tool for implementing cryptographic operations (e.g., encryption, decryption, signing, hashing) in web browsers and Node.js. It provides a standardized interface for working with cryptographic keys, ensuring secure and performant handling of sensitive operations. However, a common source of confusion is: where does the Web Crypto API store keys?

Unlike some backend key management systems (KMS) or dedicated crypto libraries, the Web Crypto API itself does not include built-in key storage. Instead, it delegates key storage to developers, who must explicitly manage where and how keys are saved (e.g., in the browser’s IndexedDB, localStorage, or server-side databases for Node.js).

This guide demystifies Web Crypto key storage, explains how to check for existing keypairs, and demonstrates safe retrieval of private keys—with actionable code examples for browsers and Node.js.

2026-01

Table of Contents#

  1. Where Does WebCrypto Store Keys?
  2. Key Storage Mechanisms: How Developers Store Keys
  3. How to Check Existing Keypairs
  4. How to Retrieve Private Keys (Safely)
  5. Best Practices for Key Storage
  6. Common Pitfalls to Avoid
  7. Conclusion
  8. References

Where Does WebCrypto Store Keys?#

The Web Crypto API is designed to generate, import, export, and manipulate keys—but it does not persist them. When you generate a key pair (e.g., RSA or ECDSA) using crypto.subtle.generateKey(), the resulting CryptoKey objects exist only in memory. Once the page reloads (browser) or the process exits (Node.js), these keys are lost unless explicitly stored.

In short: Key storage is your responsibility. You must choose where to save keys and implement logic to retrieve them later.

Browser Environments#

In browsers, keys are typically stored in client-side storage solutions. The most common options are:

  • IndexedDB: A low-level, transactional database for storing large amounts of structured data (persistent, async, and secure for sensitive data).
  • localStorage/sessionStorage: Simple key-value stores, but not secure for private keys (vulnerable to XSS attacks, synchronous, and limited in size).
  • Web Cryptography with Protected Storage: Emerging standards (e.g., Web KMS) aim to provide secure, browser-managed key storage, but adoption is limited.

Node.js Environments#

In Node.js, the Web Crypto API is implemented via the global.crypto object (available in Node.js 15+). Since Node.js runs server-side, key storage options include:

  • In-memory storage: Keys exist only for the lifetime of the process (good for ephemeral keys).
  • File system: Keys saved to disk (e.g., JSON files, PEM files).
  • Databases: Relational (PostgreSQL) or NoSQL (MongoDB) databases for scalable key management.
  • Hardware Security Modules (HSMs): For enterprise-grade security (e.g., AWS KMS, Azure Key Vault).

Key Storage Mechanisms: How Developers Store Keys#

To store keys, you first export them from the CryptoKey object into a serializable format (e.g., JSON Web Key [JWK], PEM, or raw bytes), then save that format to your chosen storage system. Later, you import the serialized key back into a CryptoKey for use.

Browser Storage Options#

IndexedDB is the gold standard for browser key storage. It supports large datasets, async operations, and is isolated per origin (reducing XSS risks compared to localStorage).

Example: Exporting and Storing a Key Pair in IndexedDB

// Generate an RSA key pair
async function generateAndStoreKeys() {
  const keyPair = await window.crypto.subtle.generateKey(
    {
      name: "RSA-OAEP",
      modulusLength: 2048,
      publicExponent: new Uint8Array([1, 0, 1]), // 65537
      hash: "SHA-256",
    },
    true, // "extractable": true to export the key later
    ["encrypt", "decrypt"] // Key usages
  );
 
  // Export keys to JWK format (JSON-serializable)
  const publicKeyJwk = await window.crypto.subtle.exportKey("jwk", keyPair.publicKey);
  const privateKeyJwk = await window.crypto.subtle.exportKey("jwk", keyPair.privateKey);
 
  // Open IndexedDB and store keys
  const db = await openIndexedDB("KeyStore", 1, (upgrade) => {
    upgrade.createObjectStore("keypairs", { keyPath: "id" });
  });
 
  const tx = db.transaction("keypairs", "readwrite");
  await tx.objectStore("keypairs").add({
    id: "user-123", // Unique identifier for the key pair
    publicKey: publicKeyJwk,
    privateKey: privateKeyJwk, // WARNING: Storing raw private keys is insecure! See Best Practices.
    createdAt: new Date().toISOString(),
  });
  await tx.done;
  db.close();
}
 
// Helper: Open IndexedDB with promises
function openIndexedDB(name, version, upgradeCallback) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(name, version);
    request.onupgradeneeded = (event) => upgradeCallback(event.target.result, event.oldVersion);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

localStorage (Avoid for Private Keys)#

localStorage is simple but risky for private keys:

  • Synchronous operations block the main thread.
  • Limited to ~5MB of storage.
  • Vulnerable to XSS attacks (attackers can steal keys via injected scripts).

Never store private keys in localStorage—use it only for public keys (if necessary).

Node.js Storage Options#

File System#

Node.js developers often store keys in JSON files (for JWK) or PEM files (for X.509).

Example: Storing a Key Pair in a JSON File

const fs = require("fs").promises;
const { crypto } = global;
 
async function generateAndStoreKeys() {
  // Generate ECDSA key pair (faster than RSA for signing)
  const keyPair = await crypto.subtle.generateKey(
    { name: "ECDSA", namedCurve: "P-256" }, // P-256 elliptic curve
    true, // extractable
    ["sign", "verify"]
  );
 
  // Export as JWK
  const publicKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
  const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
 
  // Save to file
  await fs.writeFile(
    "keys.json",
    JSON.stringify({ publicKey: publicKeyJwk, privateKey: privateKeyJwk }, null, 2)
  );
}

How to Check Existing Keypairs#

To check if a keypair exists, query your storage system for the key’s identifier (e.g., user ID, key alias).

Checking in Browsers (IndexedDB Example)#

If you stored keys in IndexedDB with a unique id (e.g., user-123), query the keypairs object store for that id:

async function checkKeyExists(userId) {
  const db = await openIndexedDB("KeyStore", 1);
  const tx = db.transaction("keypairs", "readonly");
  const keyPair = await tx.objectStore("keypairs").get(userId);
  db.close();
  return !!keyPair; // Returns true if exists
}
 
// Usage
const exists = await checkKeyExists("user-123");
console.log("Key pair exists:", exists); // true or false

Checking in Node.js (File System Example)#

For keys stored in a file, check if the file exists and contains valid key data:

const fs = require("fs").promises;
 
async function checkKeyExists(filePath) {
  try {
    await fs.access(filePath); // Throws if file doesn't exist
    const keyData = JSON.parse(await fs.readFile(filePath, "utf8"));
    return !!keyData.privateKey && !!keyData.publicKey; // Validate structure
  } catch (err) {
    return false;
  }
}
 
// Usage
const exists = await checkKeyExists("keys.json");
console.log("Key pair exists:", exists); // true or false

How to Retrieve Private Keys (Safely)#

Retrieving private keys requires exporting them from storage, then importing them back into a CryptoKey object. However, private keys must be protected—exposing them can lead to data breaches.

Security Risks of Private Key Exposure#

  • XSS Attacks: If a private key is stored in localStorage or unencrypted IndexedDB, an attacker with XSS access can steal it.
  • Data Leaks: Accidentally logging or transmitting private keys (e.g., in API responses) compromises security.
  • Unauthorized Access: Stolen private keys allow attackers to decrypt sensitive data or forge signatures.

Retrieval Example: Encrypted Private Key in Browser#

To mitigate risks, encrypt private keys before storage (e.g., with a user password). Here’s how to retrieve and decrypt a private key:

// Step 1: Retrieve encrypted private key from IndexedDB
async function getEncryptedPrivateKey(userId) {
  const db = await openIndexedDB("KeyStore", 1);
  const tx = db.transaction("keypairs", "readonly");
  const keyPair = await tx.objectStore("keypairs").get(userId);
  db.close();
  return keyPair.encryptedPrivateKey; // Assume stored as encrypted JWK
}
 
// Step 2: Decrypt the private key using a user password
async function decryptPrivateKey(encryptedKeyJwk, password) {
  // Derive a key from the password using PBKDF2
  const passwordKey = await window.crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(password),
    { name: "PBKDF2" },
    false,
    ["deriveKey"]
  );
 
  const encryptionKey = await window.crypto.subtle.deriveKey(
    { name: "PBKDF2", salt: new TextEncoder().encode("my-salt"), iterations: 100000, hash: "SHA-256" },
    passwordKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["decrypt"]
  );
 
  // Decrypt the encrypted private key JWK
  const encryptedData = new Uint8Array(encryptedKeyJwk.ciphertext);
  const decryptedJwk = JSON.parse(
    new TextDecoder().decode(
      await window.crypto.subtle.decrypt(
        { name: "AES-GCM", iv: new Uint8Array(encryptedKeyJwk.iv) },
        encryptionKey,
        encryptedData
      )
    )
  );
 
  // Import the decrypted JWK into a CryptoKey
  return window.crypto.subtle.importKey(
    "jwk",
    decryptedJwk,
    { name: "RSA-OAEP", hash: "SHA-256" },
    true,
    ["decrypt"]
  );
}
 
// Usage: Retrieve and decrypt
const encryptedKey = await getEncryptedPrivateKey("user-123");
const privateKey = await decryptPrivateKey(encryptedKey, userPassword); // userPassword from input

Retrieval Example: Node.js File System#

In Node.js, retrieve the key file, then import the private key:

const fs = require("fs").promises;
const { crypto } = global;
 
async function retrievePrivateKey(filePath) {
  const keyData = JSON.parse(await fs.readFile(filePath, "utf8"));
  return crypto.subtle.importKey(
    "jwk", // Format
    keyData.privateKey, // JWK object
    { name: "ECDSA", namedCurve: "P-256" }, // Key algorithm
    true, // extractable
    ["sign"] // Key usage
  );
}
 
// Usage
const privateKey = await retrievePrivateKey("keys.json");
console.log("Private key retrieved:", privateKey);

Best Practices for Key Storage#

  1. Encrypt Private Keys: Always encrypt private keys with a user password or hardware-bound key before storage (use AES-GCM or ChaCha20).
  2. Use IndexedDB Over localStorage: IndexedDB is more secure, async, and scalable for sensitive data.
  3. Limit Key Extractability: Set extractable: false when generating keys if they don’t need to be exported (reduces theft risk).
  4. Secure Contexts: Use HTTPS in browsers—Web Crypto requires secure contexts for most operations.
  5. Avoid Persisting Ephemeral Keys: For short-lived keys (e.g., session tokens), store them in memory only.

Common Pitfalls to Avoid#

  • Storing Keys in Plaintext: Never save unencrypted private keys (e.g., raw JWK in IndexedDB).
  • Ignoring Key Usages: Define keyUsages (e.g., ["sign"], ["decrypt"]) when generating/importing keys to restrict misuse.
  • Using Insecure Storage: localStorage is unsafe for private keys—XSS attacks can steal them.
  • Hardcoding Secrets: Never hardcode passwords or salts used for encrypting keys (use environment variables in Node.js).

Conclusion#

The Web Crypto API is a powerful tool for cryptography, but it does not manage key storage. As a developer, you must choose secure storage solutions (e.g., IndexedDB for browsers, encrypted files for Node.js) and implement best practices like encrypting private keys and using secure contexts. By following the guidelines in this guide, you can safely generate, store, and retrieve keys to protect sensitive data.

References#