On November 6, 2026, Web3Auth paused its wallet service after a flaw in its systems exposed the private keys of some Keplr Wallet users who had signed up with Google or Apple. Keplr co-founder Josh Lee estimated about 20% of those social login accounts were affected and said Keplr's own code wasn't the problem. Over the weekend Web3Auth moved about $5.6 million out of exposed wallets into 12 recovery addresses, which on-chain trackers first read as the attacker's haul (The Block).

Keplr's preliminary review said accounts created with a recovery phrase or a hardware wallet were not affected. Its help center says Google login accounts get a hex private key generated by Web3Auth instead of a 12 or 24 word phrase, and coverage of the incident describes Web3Auth's method as distributed key generation: the key lives as shares in different places and comes together at login.

Web3Auth hasn't published the root cause, so this post doesn't guess at it. What I wanted to understand is the primitive underneath: how a key can be split so that no single share means anything, and where that guarantee stops.

The idea in one line

Shamir's secret sharing hides the secret as the value of a polynomial at x = 0. For a 2-of-3 split you pick a random line f(x) = secret + a1 * x, hand out the points f(1), f(2), f(3), and throw away a1. Two points define a line, so any two shares give back f(0). One point fits infinitely many lines, so a single share tells you nothing.

The arithmetic has to happen in a finite field, otherwise the share values leak size information. For a secp256k1 wallet key the natural choice is the curve's group order N, since valid private keys are already integers mod N.

The code

Plain Node.js 18+, no dependencies. Save it as split-key.mjs.

import { randomBytes } from 'node:crypto'; // secp256k1 group order: valid private keys are integers in [1, N-1] const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; const mod = (a) => ((a % N) + N) % N; const rand = () => mod(BigInt('0x' + randomBytes(32).toString('hex'))); const inv = (a) => { // Fermat: a^(N-2) mod N, N is prime let r = 1n, b = mod(a), e = N - 2n; while (e > 0n) { if (e & 1n) r = (r * b) % N; b = (b * b) % N; e >>= 1n; } return r; }; const hex = (x) => x.toString(16).padStart(64, '0'); // Split: f(x) = secret + a1*x, share i is (i, f(i)). Any 2 points fix the line. function split(secret, n = 3) { const a1 = rand(); return Array.from({ length: n }, (_, k) => { const x = BigInt(k + 1); return { x, y: mod(secret + a1 * x) }; }); } // Combine: Lagrange interpolation at x = 0 function combine(shares) { let s = 0n; for (const { x: xi, y: yi } of shares) { let num = 1n, den = 1n; for (const { x: xj } of shares) if (xj !== xi) { num = mod(num * -xj); den = mod(den * (xi - xj)); } s = mod(s + yi * num * inv(den)); } return s; } const key = rand(); const shares = split(key); console.log('private key ', hex(key)); shares.forEach((s) => console.log(`share ${s.x} `, hex(s.y))); for (const pair of [[0, 1], [0, 2], [1, 2]]) { const got = combine(pair.map((i) => shares[i])); console.log(`shares ${pair[0] + 1}+${pair[1] + 1} -> `, got === key ? 'same key' : 'MISMATCH'); } // One share alone says nothing: for ANY candidate key there is a line through it and share 1. const guess = rand(); const slope = mod((shares[0].y - guess) * inv(shares[0].x)); console.log('share 1 alone fits a random key too:', mod(guess + slope * 1n) === shares[0].y); // The catch: whoever ends up holding two shares holds the key. console.log('attacker with shares 1+3 rebuilds it:', combine([shares[0], shares[2]]) === key); 

Output

Your hex values will differ on every run; the checks won't.

private key 8f5d2b92921cf8791535495c163aa1025ca3ec575d4b7f5ecd6a7134bb41a8b2 share 1 8cac3d676aeb94d4150b78c133b078c096c5d7c0704f7a77b5e1c92c138e7c83 share 2 89fb4f3c43ba312f14e1a8265126507ed0e7c329835375909e5921236bdb5054 share 3 874a61111c88cd8a14b7d78b6e9c283d0b09ae92965770a986d0791ac4282425 shares 1+2 -> same key shares 1+3 -> same key shares 2+3 -> same key share 1 alone fits a random key too: true attacker with shares 1+3 rebuilds it: true 

What the split protects, and what it doesn't

The math does its job. Steal one share and you have a uniformly random number. That is why a social login design can keep one share with a sign-in network, one on the user's device and one as a backup, and still say no single party holds the key.

The last line of the output is the part that matters for incidents like this one. The scheme is only as strong as the separation between share holders. If one system can reach two shares, or if the full key is ever rebuilt in a place an attacker can read, the threshold stops mattering. Real deployments use threshold signatures or DKG so the full key is never assembled at all, which shrinks that window but doesn't remove the dependency on the provider's infrastructure.

A recovery phrase sits on the other end of the trade. One copy, no math, and only the person holding the paper can rebuild it. Keplr's phrase and hardware accounts were outside this incident because nothing about them touched Web3Auth.

If you're building on embedded wallets

Read your provider's docs for where each share lives and which component rebuilds or uses the key. Find out whether users can export the key and leave, and check that they can do it during an outage, not only when your app is healthy. Keep a status page or a kill switch for deposits ready, because Keplr's first instruction to users was to stop sending funds to affected accounts.

This demo is for learning. Don't hand-roll secret sharing for production keys; use an audited library and a threshold signature scheme.

Sources: The Block (Nov 8, 2026); Keplr Help Center, "Privacy & Security"; CryptoTicker (Nov 7, 2026).