> ## Content Index
> Fetch the complete content index at: https://sudosecurity.org/llms.txt
> Use this file to discover other available public pages before exploring further.

# RSA in JavaScript: Your Numbers Are Too Small
- URL: https://sudosecurity.org/implementing-rsa-from-scratch-in-javascript/
- Published: 2023-06-02T04:00:00.000Z
- Updated: 2026-09-08T16:33:45.000Z
- Description: 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.
- Author: Traven
- Tags: Cryptography, JavaScript, RSA, Encryption, Algorithms, Programming

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`:

```javascript
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.

```javascript
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:

```javascript
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:

```javascript
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.

```javascript
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:

```javascript
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](https://sudosecurity.org/implementing-rsa-from-scratch-in-python-part-3/) 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](https://sudosecurity.org/implementing-rsa-from-scratch-in-python-part-4/) — 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.

```javascript
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.

```javascript
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:

```javascript
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.

## Don't Reach for the Popular Library Either

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](https://sudosecurity.org/implementing-rsa-from-scratch-in-javascript-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](https://sudosecurity.org/implementing-rsa-from-scratch-in-python/) 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.