RSA in JavaScript: Your Numbers Are Too Small

JavaScript cannot do RSA with numbers. Not slowly, not imprecisely — at all. The largest modulus a Number can handle is 26 bits and RSA needs 2048, and the failure is silent. Here's the implementation that works and why.

RSA in JavaScript: Your Numbers Are Too Small
Photo by Gabriel Heinzer / Unsplash

There is a specific reason RSA tutorials in JavaScript are more dangerous than RSA tutorials in Python, and it is not the language's fault. It's the type.

JavaScript's Number is a 64-bit float. Integers stay exact up to Number.MAX_SAFE_INTEGER, which is 9,007,199,254,740,991. Past that, arithmetic keeps working and quietly stops being correct.

RSA lives entirely past that point.

Watch It Fail

Here is the arithmetic RSA runs millions of times — square a number, take it modulo n:

const n = 982634309;          // a 30-bit modulus, tiny by RSA standards
let b = 123456789;
 
const sq = b * b;
console.log(sq);                              // 15241578750190520
console.log(BigInt(b) * BigInt(b));           // 15241578750190521n

Off by one. No exception, no warning, no NaN. The float rounded and moved on.

Carry that error into the modulo and it propagates:

  (b*b) % n      = 871887296
  exact          = 871887297

That is one squaring. RSA does thousands per operation, and every one of them can be off.

The boundary is computable. A modular multiplication needs to hold (n-1)² exactly, so the largest safe modulus is sqrt(MAX_SAFE_INTEGER):

  sqrt(MAX_SAFE_INTEGER) = 94906265  (~26 bits)
  our n = 982634309 (30 bits) -> UNSAFE
  RSA-2048 needs 2048-bit n -> off by a factor of 2^2022

Twenty-six bits. That's the ceiling. RSA-2048 needs 2048. You are not slightly short, you are short by a factor of two-to-the-two-thousand-and-twenty-two.

Which means any JavaScript RSA implementation built on Number is wrong. Not "wrong for large keys" — wrong for the toy 30-bit example too, as the next section shows.

The Version That Doesn't Run

The previous version of this article shipped an implementation with two undefined functions. Running it verbatim:

ReferenceError: lcm is not defined
    at generateRSAKeys

generateRSAKeys called lcm() and euclideanAlgorithm(). The article defined extendedEuclidean() and modularInverse(). Neither of the called names existed.

That's a copy-paste failure, and it's the less interesting bug. Supply the two missing functions and it runs — and produces this:

  d = 183127409 | e*d mod lambda = 1n
  encrypted = 308457138
  decrypted = 54592692 <-- WRONG, should be 80087

The key generation is fine — e*d ≡ 1 (mod λ(n)) holds exactly. The encryption is wrong. The correct ciphertext for that key and message is 810476878; Number produced 308457138, and decryption returned garbage.

A working-looking round-trip that returns the wrong number, on a 30-bit key, with no error at any point. That's the failure mode this whole article exists to prevent.

There were two smaller problems worth fixing while we're here. The exponent loop used Math.floor(exponent / 2) rather than a bit shift, which is correct for Number and does not work for BigInt. And step 3 of the key generation described λ(n) = lcm(p-1, q-1) as "Euler's Totient Function." That's the Carmichael function. Euler's totient is φ(n) = (p-1)(q-1). Both produce working keys — λ divides φ, so any d valid mod φ is valid mod λ — but λ gives you a smaller d, and FIPS 186-5 requires it.

The Implementation That Works

BigInt arrives with ES2020 and is arbitrary-precision. The literals take an n suffix, and you cannot mix BigInt and Number in the same expression — which is annoying for about ten minutes and then saves you from exactly the bug above.

export function extendedEuclidean(a, b) {
  if (a === 0n) return [b, 0n, 1n];
  const [g, x1, y1] = extendedEuclidean(b % a, a);
  return [g, y1 - (b / a) * x1, x1];   // BigInt / truncates, which is what we want
}
 
export function modularInverse(a, m) {
  const [g, x] = extendedEuclidean(a % m, m);
  if (g !== 1n) return null;           // no inverse exists
  return ((x % m) + m) % m;            // normalise to a positive representative
}
 
export function modularExponentiation(base, exponent, modulus) {
  if (modulus === 1n) return 0n;
  let result = 1n;
  base %= modulus;
  while (exponent > 0n) {
    if (exponent & 1n) result = (result * base) % modulus;
    exponent >>= 1n;                   // NOT Math.floor(exponent / 2)
    base = (base * base) % modulus;
  }
  return result;
}

Three details in there matter.

b / a on BigInts truncates toward zero, which is exactly the integer division the extended Euclidean algorithm needs. Writing Math.floor(b / a) would throw, because Math.floor doesn't accept a BigInt. The type system is doing you a favour.

The recursion has a depth you should check. Lamé's theorem says the worst input to Euclid is a pair of consecutive Fibonacci numbers, and at 2048 bits that means:

  recursion depth (Fibonacci worst case): 2949
  recursion depth (e=65537 vs 2048-bit lambda): 12
  Node stack limit: ~12534 frames

Twelve frames in the realistic case, because e = 65537 is small and collapses the algorithm almost immediately. But the worst case is 2,949, and that is only about four times under Node's limit — a margin that varies by runtime, and is smaller in browsers and in workers with reduced stacks.

The iterative form has no such ceiling and is not harder to read:

function extendedEuclideanIterative(a, b) {
  let [oldR, r] = [a, b];
  let [oldS, s] = [1n, 0n];
  let [oldT, t] = [0n, 1n];
  while (r !== 0n) {
    const q = oldR / r;
    [oldR, r] = [r, oldR - q * r];
    [oldS, s] = [s, oldS - q * s];
    [oldT, t] = [t, oldT - q * t];
  }
  return [oldR, oldS, oldT];
}

Verified against the recursive version on 500 random pairs, with the Bezout identity a·x + b·y = gcd(a,b) checked each time:

  iterative agrees with recursive + Bezout holds: 500/500
  4096-bit Fibonacci worst case: gcd=1, 8.4ms, no stack limit

Use this one. The recursive version is clearer for explaining the algorithm and worse for running it.

exponent >>= 1n instead of Math.floor(exponent / 2). Same reason. Bit-shifting is also what the algorithm actually means.

modularInverse returns null when no inverse exists rather than pretending. The caller has to handle it, which is the point.

Key generation with the coprimality check the original skipped:

function gcd(a, b) { while (b) { [a, b] = [b, a % b]; } return a; }
function lcm(a, b) { return (a / gcd(a, b)) * b; }
 
export function generateRSAKeys(p, q, e = 65537n) {
  const n = p * q;
  const lambdaN = lcm(p - 1n, q - 1n);   // Carmichael, not Euler's totient
  if (gcd(e, lambdaN) !== 1n) {
    throw new Error(`e=${e} shares a factor with lambda(n); pick different primes`);
  }
  const d = modularInverse(e, lambdaN);
  return { publicKey: { e, n }, privateKey: { d, n } };
}
 
export const encrypt = (m, pub)  => modularExponentiation(m, pub.e, pub.n);
export const decrypt = (c, priv) => modularExponentiation(c, priv.d, priv.n);

That throw is not defensive padding. If gcd(e, λ(n)) ≠ 1 there is no modular inverse, and an unguarded implementation returns a d that silently fails to decrypt anything.

Running it on the same inputs the broken version used:

  publicKey : { e: 65537n, n: 982634309n }
  privateKey: { d: 183127409n, n: 982634309n }
  80087 -> 810476878 -> 80087  OK
 
  cross-check against Number version:
    Number said encrypted = 308457138 (wrong)
    BigInt says encrypted = 810476878 (correct)
 
  round-trip on 200 random messages: 200/200

Same key, same message, different ciphertext — and this one round-trips. Two hundred random messages, no failures.

Generating Actual Primes

randomBigInt gives you candidates. Turning those into primes needs a primality test, and at 1024 bits that means Miller-Rabin — trial division is hopeless and deterministic tests are far too slow.

const SMALL = [3n,5n,7n,11n,13n,17n,19n,23n,29n,31n,37n,41n,43n,47n];
 
function isProbablePrime(n, rounds = 20) {
  if (n < 2n) return false;
  for (const p of SMALL) { if (n % p === 0n) return n === p; }
 
  let d = n - 1n, r = 0n;
  while (d % 2n === 0n) { d /= 2n; r++; }        // n-1 = d * 2^r, d odd
 
  for (let i = 0; i < rounds; i++) {
    const a = 2n + randomBigInt(64) % (n - 4n);
    let x = modularExponentiation(a, d, n);
    if (x === 1n || x === n - 1n) continue;       // probably prime, next witness
 
    let witness = true;
    for (let j = 0n; j < r - 1n; j++) {
      x = (x * x) % n;
      if (x === n - 1n) { witness = false; break; }
    }
    if (witness) return false;                    // definitely composite
  }
  return true;
}
 
function generatePrime(bits) {
  for (;;) {
    const c = randomBigInt(bits);
    if (isProbablePrime(c)) return c;
  }
}

That SMALL loop at the top looks like a micro-optimisation and isn't:

  small-prime sieve rejects 72.7% of candidates before any Miller-Rabin
  generatePrime(512): 22ms, 512 bits
  error bound with 20 rounds: 4^-20 = 9.095e-13

Nearly three quarters of candidates die on fourteen cheap modulo operations, before a single expensive modular exponentiation runs. Skip that loop and prime generation takes roughly four times longer for identical output.

Twenty rounds is the standard choice because Miller-Rabin's error bound is 4^(-rounds) — a composite slipping through has probability below 10^-12, which is well past the point where hardware failure is the likelier explanation.

One detail that matters for correctness: the witness a must be drawn randomly. Fixed witnesses have known counterexamples, and an attacker who knows which witnesses you use can construct a composite that passes all of them.

Six BigInt Behaviours That Will Catch You

If you have not used BigInt in anger, these are the ones that show up. Five throw loudly, which is good. One is silent, which is not.

  1. JSON.stringify on a BigInt:
     TypeError: Do not know how to serialize a BigInt
 
  2. Mixing BigInt and Number:
     TypeError: Cannot mix BigInt and other types, use explicit conversions
 
  3. Comparison operators DO mix (asymmetric with arithmetic):
     1n == 1  -> true
     1n === 1 -> false
     2n > 1   -> true
 
  4. Math.* rejects BigInt:
     TypeError: Cannot convert a BigInt value to a number
 
  5. Division truncates, it does not round:
     7n / 2n = 3   (Number: 3.5)
     -7n / 2n = -3  (truncates toward zero)
 
  6. Number(bigint) loses precision silently:
     1152921504606846977 -> Number -> 1152921504606846976  equal? false

Number 3 is the odd one: arithmetic between the two types throws, but comparison is permitted, and == is true while === is false. That inconsistency is deliberate — comparison has an unambiguous answer, arithmetic doesn't, because the result type would be ambiguous.

Number 5 is what makes the extended Euclidean algorithm work. b / a on BigInts is integer division truncating toward zero, which is exactly the operation the algorithm calls for. In the Number version you have to write Math.floor(b / a) and remember to.

Number 6 is the dangerous one, and it is the same failure this whole article opened with, wearing a different hat. Number(someBigInt) on anything above 2^53 silently rounds. If you have carefully done all your arithmetic in BigInt and then convert to Number to log it, index an array with it, or hand it to a library, you have just thrown the correctness away at the last step.

And since number 1 means you cannot JSON.stringify a key, serialise the components as strings:

  {"e":"65537","n":"982634309"}

BigInt("65537") reads it back. Real formats use base64url of the big-endian bytes — that is what the e: "AQAB" in a JWK is.

It Also Isn't Fast Enough

Correctness is one question. Whether pure BigInt can carry real traffic is another, and the answer is measurable. Generating a genuine 2048-bit key with the code above and timing one operation of each kind:

  key generation: 2.3s
  n = 2048 bits
  one decryption (2048-bit d): 20 ms  -> correct
  one encryption (e=65537):    0.05 ms
 
  => ~50 decryptions/sec on one core

Two things stand out.

Encryption is 400× cheaper than decryption. That is not an implementation quirk, it's the whole shape of RSA: e = 65537 has two bits set, so encryption costs about 17 squarings and one multiply. The private exponent d is a full 2048 bits with roughly half of them set, so decryption costs about 2048 squarings and 1024 multiplies. Every RSA system is lopsided this way, which is why servers doing many decryptions per second care about CRT and clients doing verifications don't.

Fifty decryptions per second, on one core, and JavaScript is single-threaded. For a login endpoint that is a hard ceiling you will hit.

The same operations through Web Crypto, same machine:

  Web Crypto decrypt: 0.42 ms  (~2381/sec)
  Web Crypto encrypt: 0.05 ms
  pure BigInt decrypt was 20 ms -> Web Crypto is 48x faster
  Web Crypto keygen:  21 ms  (BigInt version took 2300 ms)

48× on decryption and 110× on key generation. That gap is BoringSSL's optimised bignum arithmetic and CRT decryption against a textbook loop — and the CRT part alone accounts for roughly a 3–4× share of it.

So the summary on pure BigInt RSA: correct, roughly fifty times too slow, and leaking the key through timing while it works. Educational only, and this is what "educational only" actually means quantitatively.

What This Still Can't Do

The implementation above is arithmetically correct and nowhere near usable. Same list as any from-scratch RSA:

No padding. Textbook RSA is deterministic, so identical plaintexts produce identical ciphertexts, and it's malleable — multiply a ciphertext by f^e mod n and the plaintext becomes f × m. OAEP fixes both.

Toy primes. Hand-picked 15-bit values. Real RSA needs ~1024-bit primes from a cryptographically secure source, and Math.random() is not one — V8 implements it with xorshift128+, a 128-bit-state PRNG that is solvable from observed output. Every value it will ever produce becomes predictable, which for key material means your primes are too.

The correct source is crypto.getRandomValues(), and turning its bytes into a candidate prime takes a little care:

function randomBigInt(bits) {
  const bytes = new Uint8Array(Math.ceil(bits / 8));
  crypto.getRandomValues(bytes);
  let x = 0n;
  for (const b of bytes) x = (x << 8n) | BigInt(b);
  return x | (1n << BigInt(bits - 1)) | 1n;   // force top bit and odd
}
  randomBigInt(1024): 1024 bits, odd = true
  top bit set = true

The two | operations at the end are not decoration. Forcing the top bit guarantees you actually get a 1024-bit number rather than an accidental 900-bit one, which would give you a weak modulus. Forcing it odd skips half the candidates for free, since no even number above 2 is prime.

Message size. m must be smaller than n, which for real use means chunking and padding.

Timing leaks. modularExponentiation branches on the bits of the exponent, so its running time depends on the private key.

And one that is specific to this language, with numbers rather than hand-waving.

BigInt is not constant-time, and the specification never promised it would be. The exponent leak is large. Two exponents of identical bit length against a 2048-bit modulus, one with 2 bits set and one with 1024:

  modularExponentiation, 2 bits set   :  5.74 ms
  modularExponentiation, 1024 bits set: 11.93 ms
  ratio: 2.076x

More than double. For comparison, the equivalent Python measurement in the Python series came out at 1.09× — an 8% signal that is still enough to mount a statistical attack. JavaScript leaks the exponent's Hamming weight about twelve times more strongly, because the square-and-multiply loop skips the multiply entirely on a zero bit and BigInt multiplication is expensive.

That is the private key during decryption, broadcast through the clock at a 2× amplitude.

There is a second leak that survives even if you fix the first. BigInt stores only the words it needs, so operations on smaller values are cheaper — including the conversion back to bytes at the end:

  toString(16), 1401-bit value: 383.4 ns
  toString(16), 2040-bit value: 544.9 ns
  ratio: 1.421x

Forty percent, on an operation that runs after any blinding has done its job and is handling the real plaintext. That is precisely the finding of the Marvin Attack — Kario's paper names arbitrary-precision integer libraries generically, listing Python's int, Java's BigInteger and Go's math/big. JavaScript's BigInt is the same construction and inherits the same problem.

So the honest position on BigInt: it is necessary and not sufficient. It fixes correctness. It does not give you security, and in JavaScript it leaks harder than the languages usually held up as examples.

The Answer for Anything Real

JavaScript has had a proper cryptography API for years, it needs no dependencies, and it is almost certainly already available to you.

One deployment caveat before the code: in browsers, crypto.subtle is only exposed in a secure context — HTTPS, or localhost. On a plain http:// page it is undefined, and the failure looks like a missing API rather than a policy decision. In Node it has been available as a global since v19.

const enc = new TextEncoder(), dec = new TextDecoder();
 
const keyPair = await crypto.subtle.generateKey(
  { name: 'RSA-OAEP', modulusLength: 2048,
    publicExponent: new Uint8Array([0x01, 0x00, 0x01]),   // 65537
    hash: 'SHA-256' },
  true, ['encrypt', 'decrypt']
);
 
const ciphertext = await crypto.subtle.encrypt(
  { name: 'RSA-OAEP' }, keyPair.publicKey, enc.encode('attack at dawn'));
const plaintext = await crypto.subtle.decrypt(
  { name: 'RSA-OAEP' }, keyPair.privateKey, ciphertext);

Run it against the failures listed above:

  round-trip: attack at dawn
  ciphertext bytes: 256
  exported JWK e: AQAB -> 65537
  same plaintext -> different ciphertext: true
  tampered ciphertext rejected: OperationError
  max plaintext for RSA-OAEP/SHA-256 @2048: 190 bytes

Every gap closed. Non-deterministic encryption, so identical plaintexts don't reveal themselves. A flipped bit raises OperationError instead of returning plausible garbage. e = 65537 by default — that new Uint8Array([0x01, 0x00, 0x01]) is 65537 in big-endian, and the exported JWK confirms it round-trips as AQAB.

That last line is worth knowing before you design around it: 190 bytes. RSA-OAEP with SHA-256 on a 2048-bit key can encrypt 190 bytes total, because the padding consumes 2 × hashLength + 2 = 66 bytes of the 256 available.

If you are trying to encrypt more than 190 bytes with RSA, you are using RSA wrong. Generate an AES key, encrypt the data with AES-GCM, encrypt the AES key with RSA. That's what TLS does and it's what you should do.

Web Crypto covers the signature side too. Checked in Node 22:

  RSA-OAEP             supported
  RSASSA-PKCS1-v1_5    supported
  RSA-PSS              supported
 
  ML-KEM-768           NOT supported (NotSupportedError)
  X25519               supported
  Ed25519              supported

Use RSA-PSS for new signatures rather than RSASSA-PKCS1-v1_5. The latter is the padding the Marvin work found problems around, and PSS has a proper security proof.

And note the last three rows, because they tell you where this is heading. Ed25519 and X25519 are there; ML-KEM is not. If you want post-quantum key exchange in JavaScript today it is not in the platform API yet, which is a reason to design for algorithm agility rather than to hardcode RSA anywhere new.

Getting Keys In and Out

generateKey returns opaque CryptoKey objects. To store or transmit them you export, and the format you pick tells you something.

const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
  JWK public key fields:  key_ops, ext, alg, kty, n, e
    kty=RSA alg=RSA-OAEP-256 e=AQAB n=qkruZl2GSJTohdKsLAlK... (342 b64 chars)
 
  JWK private key fields: key_ops, ext, alg, kty, n, e, d, p, q, dp, dq, qi

Look at the difference between those two lines. The public key is n and e — exactly the two values this article's generateRSAKeys returns. The private key is eight values: n, e, d, p, q, dp, dq, qi.

dp and dq are d mod (p-1) and d mod (q-1); qi is q⁻¹ mod p. Those are the Chinese Remainder Theorem parameters, and they exist because CRT decryption is several times faster than the naive c^d mod n this article implements. A real private key keeps the factors around precisely so it never has to do the slow thing.

That's also why e = AQAB keeps showing up in JWKs — it's base64url of 0x010001, which is 65537.

For PEM, export SPKI and wrap it:

const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
const b64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
const pem = `-----BEGIN PUBLIC KEY-----\n${b64.match(/.{1,64}/g).join('\n')}\n-----END PUBLIC KEY-----`;
  SPKI DER: 294 bytes -> PEM 9 lines
  -----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqkruZl...
  PEM round-trip: via PEM

Reading it back is importKey('spki', der, ...) after stripping the header lines and base64-decoding. Verified above by encrypting with the re-imported key and decrypting with the original private key.

One flag to notice at generation time: the true argument to generateKey is extractable. Pass false and the private key can never be exported — it can only be used. For a key that lives in a browser and signs things, that is usually what you want, and it is one of the few genuine security guarantees the platform gives you that your own code cannot.

The usual next step after "don't write your own" is npm. Two packages dominate JavaScript RSA — node-forge and jsencrypt — and the recent history is worth knowing before you install either.

In April 2026, CERT/CC published VU#725167 covering two signature-forgery vulnerabilities in node-forge:

CVE-2026-33894 — RSASSA-PKCS#1 v1.5 verification accepted forged signatures. Two flaws combined: the ASN.1 parser permitted non-canonical DigestInfo encodings including attacker-controlled extra fields, and it accepted undersized padding. Affected versions 0.1.2 through 1.3.3.

A companion Ed25519 issue accepted non-canonical signature scalars, affecting 0.7.4 through 1.3.3.

The sentence from the advisory that should end the discussion:

node-forge accepts signatures that appear valid internally but are rejected by industry-standard libraries such as OpenSSL and Node.js's native crypto module.

That is a signature-verification bypass in the most-downloaded JavaScript crypto library, and it is the same shape as attacks that have been known since the 1990s — permissive padding checks in PKCS#1 v1.5. Both were fixed in v1.4.0, released 2026-04-05, which enforces canonical DigestInfo and requires at least 8 bytes of padding.

That followed CVE-2025-12816 in November 2025, also an ASN.1 validation issue in the same library, also rated high.

If you have node-forge anywhere in your tree — including transitively, through a framework or build tool — check the version. The advisory is explicit that indirect usage exposes you until the transitive dependency is upgraded.

And to be fair rather than one-sided: Node's own WebCrypto shipped a CVE in June 2026 too, where subtle.encrypt() could crash the process on input that is a multiple of 2GiB. Platform APIs are not bug-free.

The difference is what kind of bug. A crash on a 2GiB input is a denial of service you will notice. A signature-verification bypass is an attacker forging credentials and nobody noticing at all. Both get patched; only one of them is silently exploitable.

Why Build It At All

You shouldn't ship it. You should still write it, because crypto.subtle.generateKey is opaque until you know what it's generating.

After the above, modulusLength: 2048 means the bit length of n = p × q. publicExponent is those three bytes because 65537 is 0x010001 and has only two bits set, making encryption cheap. RSA-OAEP is the padding that makes encryption non-deterministic. And the reason crypto.subtle returns opaque CryptoKey objects rather than numbers you can inspect is precisely so you cannot make the mistakes in the first half of this article.

The deeper lesson is one JavaScript teaches more brutally than most languages: a cryptographic implementation that produces output is not the same as one that produces correct output. The broken version above generated keys, ran, printed a number, and was wrong. Nothing anywhere in that pipeline complained.

Part 2 picks up where this leaves off: RS256 JWTs, the two attacks that turn signature verification into an authentication bypass, RSA-PSS, and the hybrid pattern that gets you past the 190-byte ceiling.

If you want the same material worked through in more depth — prime generation, block encryption, timing attacks and the post-quantum timeline — the Python series goes considerably further, and every bug in it was found the same way: by running the code and checking the output against a reference, rather than trusting that it looked right.