> ## 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 Part 2: The JWT Is Where You'll Actually Meet It
- URL: https://sudosecurity.org/implementing-rsa-from-scratch-in-javascript-part-2/
- Published: 2026-04-30T16:36:00.000Z
- Updated: 2026-09-08T16:37:09.000Z
- Description: Most JavaScript developers never call an RSA function directly. They verify an RS256 token, and two classic attacks turn that into an authentication bypass — both demonstrated here, both fixed by one line.
- Author: Traven
- Tags: Cryptography, RSA, Algorithms, Encryption, Programming, JavaScript

[Part 1](https://sudosecurity.org/implementing-rsa-from-scratch-in-javascript/) covered why `Number` cannot do RSA, what `BigInt` fixes, and why you should use Web Crypto anyway. It ended on encryption.

Encryption is not how most JavaScript developers meet RSA. **Signatures are.** Specifically, an `RS256` JSON Web Token — in an `Authorization` header, on every request, deciding whether the caller is an administrator.

That is a much more interesting attack surface than encryption, because the failures are not subtle arithmetic bugs. They are one-line logic mistakes that hand out admin access, and they have been in production at large companies repeatedly.

## A Working RS256 Token

JWTs are three base64url segments joined by dots: header, payload, signature. The signature covers `header.payload` — the literal encoded string, not the decoded JSON.

```javascript
const b64url = (buf) => Buffer.from(buf).toString('base64url');
const enc = new TextEncoder();
 
async function signJWT(payload, privateKey) {
  const header = { alg: 'RS256', typ: 'JWT' };
  const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
  const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', privateKey, enc.encode(signingInput));
  return `${signingInput}.${b64url(sig)}`;
}
 
async function verifyJWT(token, publicKey) {
  const [h, p, s] = token.split('.');
  const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey,
    Buffer.from(s, 'base64url'), enc.encode(`${h}.${p}`));
  return ok ? JSON.parse(Buffer.from(p, 'base64url')) : null;
}

```

```
  token length: 443
  header: {"alg":"RS256","typ":"JWT"}
  verify: {"sub":"traven","admin":false,"exp":1800000000}
  signature bytes: 256

```

`RS256` in JWT terms means RSASSA-PKCS#1 v1.5 with SHA-256, which is Web Crypto's `RSASSA-PKCS1-v1_5`. The 256-byte signature is the modulus size — 2048 bits — same as Part 1's ciphertexts, and for the same reason.

**The payload is not encrypted.** It's base64url, which is an encoding, not a cipher. Anyone holding the token reads the claims. The signature proves nobody *changed* them.

One structural detail that saves you from a whole class of bug: **the signature covers the encoded string, not the decoded JSON.** That matters because base64url decoding is permissive — Node happily accepts padding, embedded whitespace and newlines, all decoding to the same bytes:

```
  canonical              -> decodes to SAME payload
  with = padding         -> decodes to SAME payload
  trailing whitespace    -> decodes to SAME payload
  embedded newline       -> decodes to SAME payload

```

In a scheme that signed the *decoded* value, those variants would all carry a valid signature while being different tokens — exactly the non-canonical-encoding problem behind the node-forge ASN.1 forgeries in [Part 1](https://sudosecurity.org/implementing-rsa-from-scratch-in-javascript/). JWT dodges it because `verify` is handed the literal `header.payload` string: change a byte anywhere, including whitespace, and the signature fails.

That is a good design choice worth recognising, and it holds only as long as you verify the string you received rather than one you re-encoded after parsing. Decode-then-re-encode-then-verify reintroduces the problem.

## Attack 1: The Header Says Not To Bother

The JWT header carries an `alg` field. Some verifiers read it and dispatch on it. That is the bug, and it's worth seeing how small it looks:

```javascript
async function naiveVerify(token) {
  const [h, p, s] = token.split('.');
  const alg = JSON.parse(dec(h)).alg;
  if (alg === 'none') return JSON.parse(dec(p));   // <-- the bug
  return await crypto.subtle.verify(/* ... */) ? JSON.parse(dec(p)) : null;
}

```

`alg: none` exists in the JWT specification for tokens whose integrity is guaranteed by other means. If your verifier honours it, an attacker sets the header to `none`, edits the payload, drops the signature entirely, and is done.

```
  forged token payload: {"sub":"traven","admin":true}
  naive verifier accepts: {"sub":"traven","admin":true}

```

Note what the attacker needed: nothing. No key, no crypto, no timing measurements. A base64 encoder and the knowledge that `admin` is a field.

## Attack 2: Your Public Key Is the HMAC Secret

This one is more elegant and considerably worse.

JWT supports both asymmetric algorithms (`RS256`, using an RSA key pair) and symmetric ones (`HS256`, using an HMAC shared secret). A verifier that dispatches on the header's `alg` has to handle both, and typically has one key configured — the RSA public key.

The attacker changes `alg` to `HS256` and signs the token with **the RSA public key as the HMAC secret**.

That key is public. It's in your JWKS endpoint, your `.well-known`, your documentation.

```javascript
async function confusedVerify(token) {
  const [h, p, s] = token.split('.');
  const alg = JSON.parse(dec(h)).alg;
  if (alg.startsWith('HS')) {                      // <-- the bug
    const expect = createHmac('sha256', pubPem).update(`${h}.${p}`).digest();
    return Buffer.compare(expect, Buffer.from(s,'base64url')) === 0 ? JSON.parse(dec(p)) : null;
  }
  return await crypto.subtle.verify(/* RSA path */) ? JSON.parse(dec(p)) : null;
}

```

```
  attacker used the PUBLIC key as an HMAC secret
  confused verifier accepts: {"sub":"traven","admin":true}

```

The verifier is doing exactly what it was told. It sees `HS256`, reaches for its configured key, and validates the HMAC correctly. The vulnerability is that the same bytes serve as a *public* value in one algorithm and a *secret* in the other, and the token got to choose which.

One detail that makes this harder to spot in the wild: the attacker must use the public key in precisely the encoding the server uses — PEM including the trailing newline, or DER, or whichever. That's a handful of attempts, not a barrier.

## The Fix Is One Line

Both attacks come from the same mistake: **treating attacker-controlled input as configuration.** The `alg` header arrives with the token; the token arrives from whoever is calling you.

```javascript
async function safeVerify(token, publicKey) {
  const [h, p, s] = token.split('.');
  const hdr = JSON.parse(dec(h));
  if (hdr.alg !== 'RS256') return null;            // pinned, not read
  return await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey,
    Buffer.from(s, 'base64url'), enc.encode(`${h}.${p}`)) ? JSON.parse(dec(p)) : null;
}

```

Same three tokens through it:

```
  alg:none         -> null
  HS256 confusion  -> null
  legitimate       -> {"sub":"traven","admin":false}

```

The algorithm is a property of *your deployment*, not of the incoming token. You already know you issue RS256\. Check that the header matches what you expect and reject anything else — never look it up and dispatch.

Web Crypto pushes you toward this by design: `crypto.subtle.verify()` takes the algorithm as its first argument, and a `CryptoKey` generated for `RSASSA-PKCS1-v1_5` cannot be used for HMAC. Reaching for a library that infers the algorithm from the token is the step that reintroduces the problem.

### This Is Not Folklore

Both attacks are written into the specification's own threat model. **RFC 8725, JSON Web Token Best Current Practices**, describes them in consecutive sentences:

> The algorithm can be changed to "none" by an attacker, and some libraries would trust this value and "validate" the JWT without checking any signature. An "RS256" (RSA, 2048 bit) parameter value can be changed into "HS256" (HMAC, SHA-256), and some libraries would try to validate the signature using HMAC-SHA256 and using the RSA public key as the HMAC shared secret.

The RFC's rule is unambiguous: the library **must** allow specifying acceptable algorithms server-side, each key maps to exactly one algorithm, and you never trust `alg` from the header.

The history is the part that should worry you. Node's **`jsonwebtoken`** — one of the most-downloaded packages on npm — was vulnerable to exactly this before version 4.2.2\. That's **CVE-2015-9235**, from 2015\. The fix was adding an `algorithms` option that callers must supply explicitly.

Eleven years later, **CVE-2026-22817** is the same vulnerability in **Hono**. Six critical JWT CVEs were disclosed across widely-deployed libraries in 2025 alone.

This is not a bug that got fixed. It is a bug that gets reintroduced every time somebody writes a verifier that reads the header.

### Three More Things That Are Still Your Problem

Beyond the signature itself:

**Verify `exp`.** Without it a stolen token is valid forever.

**Verify `iss` and `aud`.** Otherwise a token minted for a different service by the same identity provider validates against yours.

**Never fetch keys from the token.** The `jku` and `x5u` headers name a URL to fetch the verification key from — point them at an attacker's server and it supplies a key matching its own signature. The `kid` header names a key identifier, and passing it unsanitised into a lookup has produced both SQL injection and path traversal in real systems. Both headers are attacker-controlled data. Match `kid` against a fixed allowlist; never dereference `jku`.

## PSS Versus PKCS#1 v1.5

Part 1 noted that `RSA-PSS` is preferable to `RSASSA-PKCS1-v1_5` for new work. Here is the difference in behaviour.

```
  PKCS#1 v1.5: same signature twice? true
  RSA-PSS    : same signature twice? false

```

**PKCS#1 v1.5 is deterministic. PSS is randomised**, because it mixes a random salt into the padding. Same key, same message, different signature every time, all of them valid.

That randomisation is why PSS has a security proof and v1.5 does not. The deterministic construction is what made the node-forge forgery bugs in [Part 1](https://sudosecurity.org/implementing-rsa-from-scratch-in-javascript/) possible — verification amounted to reconstructing an expected value and comparing, and a parser that was too permissive about *what* it reconstructed accepted forged inputs.

```
  both verify: true true
  signature sizes: 256 256 bytes
  verify with WRONG saltLength (20 vs 32): false

```

Same signature size, so PSS costs nothing in bandwidth. The last line is the operational gotcha: **`saltLength` is not carried in the signature.** Verification must be told the same value signing used, and a mismatch is a silent `false` rather than an error. Standard practice is to match the hash length — 32 for SHA-256.

JWT calls PSS `PS256`. If you control both ends, use it.

## Compare a Timestamp, Not a Signature

One detail in the vulnerable `confusedVerify` above is worth pulling out, because it is a bug in its own right and I put it there:

```javascript
return Buffer.compare(expect, Buffer.from(s,'base64url')) === 0 ? ... : null;

```

`Buffer.compare` is a `memcmp`. It **exits at the first differing byte**, so how long it runs depends on how much of the attacker's guess was correct. Feed it guesses and time the responses and you can recover the expected value byte by byte, without ever knowing the key.

Whether that's exploitable depends on size, and the numbers are interesting:

```
      32 bytes  Buffer.compare: early 21.5 ns | late 21.0 ns | ratio 0.98x
    1024 bytes  Buffer.compare: early 20.4 ns | late 27.1 ns | ratio 1.33x
   65536 bytes  Buffer.compare: early 19.7 ns | late 980.2 ns | ratio 49.87x

```

At 64KB the leak is **fifty times**, unmissable. At 1KB it's a third. At 32 bytes — the size of an HMAC-SHA256 tag — it disappears into the noise of this harness, because `memcmp` works a machine word at a time and 32 bytes is four comparisons.

**That is not the same as safe.** It means my measurement floor is above the signal, and a real attacker measuring over a network with thousands of samples and statistical aggregation has a much better instrument than a `for` loop. The mechanism is proven at larger sizes; the small case is unmeasured, not absent.

Use the primitive that removes the question:

```javascript
import { timingSafeEqual } from 'node:crypto';
// throws if lengths differ, so check that separately first
const ok = expect.length === actual.length && timingSafeEqual(expect, actual);

```

```
  timingSafeEqual:
    differs at byte 0 : 63.68 ns
    differs at byte 31: 63.67 ns
    ratio: 1.000x

```

Exactly 1.000\. It always reads every byte, which is why it costs three times as much and why that cost is the entire point.

Two notes. It **throws** if the buffers are different lengths rather than returning false, so compare lengths first — and note that length itself is not secret here, since a signature's length is fixed by the algorithm. And in browsers there is no equivalent; if you are comparing MACs in front-end code, you are almost certainly doing something that belongs on a server.

None of this applies to `crypto.subtle.verify()`, which handles the comparison internally. It applies the moment you compare two byte strings yourself.

## Should It Be RSA At All?

JWT supports several signature algorithms. Since Part 1 established that RSA is the slow, large option, here is what the alternatives cost — same message, same machine, Web Crypto throughout:

```
  algorithm                            sign   verify    sig  pubkey
  RS256 (RSASSA-PKCS1-v1_5 2048)     0.34ms   0.04ms    256     294
  PS256 (RSA-PSS 2048)               0.34ms   0.04ms    256     294
  ES256 (ECDSA P-256)                0.07ms   0.11ms     64      91
  EdDSA (Ed25519)                    0.04ms   0.11ms     64      44

```

The asymmetry is the whole decision, and it runs in both directions.

**RSA verifies fastest** — 0.04ms, roughly three times quicker than the elliptic-curve options. That is the `e = 65537` effect from Part 1: two set bits, so verification is cheap. **RSA signs slowest** — 0.34ms, about 8.5× slower than Ed25519, because the private exponent is 2048 bits with half of them set.

So the right answer depends on which side you are on. An identity provider **signing** thousands of tokens per second cares about the 0.04 vs 0.34\. A gateway **verifying** thousands cares about the reverse.

The sizes are less ambiguous. **Ed25519 public keys are 44 bytes against RSA's 294**, and signatures are 64 against 256\. In a JWT that ends up in every request header, 192 bytes per token adds up in a way that shows on a bandwidth bill.

For a new system with both ends under your control, **EdDSA** is the better default: fastest signing, smallest keys, no padding to get wrong, no `saltLength` to mismatch. RSA remains the answer when you must interoperate with something that only speaks RS256 — which, given how much of the identity ecosystem was built around it, is often.

## Hybrid Encryption, Because 190 Bytes Isn't Enough

Part 1 ended on a limit: RSA-OAEP with SHA-256 on a 2048-bit key encrypts **190 bytes**, total. That is not a bug to work around — RSA was never meant to carry payloads.

The pattern every real system uses, TLS included: generate a random symmetric key, encrypt the data with it, and use RSA only to encrypt that key.

```javascript
async function hybridEncrypt(data, rsaPublicKey) {
  const aesKey = await crypto.subtle.generateKey({name:'AES-GCM', length:256}, true, ['encrypt']);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt({name:'AES-GCM', iv}, aesKey, data);
  const rawAes = await crypto.subtle.exportKey('raw', aesKey);
  const wrappedKey = await crypto.subtle.encrypt({name:'RSA-OAEP'}, rsaPublicKey, rawAes);
  return { wrappedKey, iv, ciphertext };
}
 
async function hybridDecrypt({wrappedKey, iv, ciphertext}, rsaPrivateKey) {
  const rawAes = await crypto.subtle.decrypt({name:'RSA-OAEP'}, rsaPrivateKey, wrappedKey);
  const aesKey = await crypto.subtle.importKey('raw', rawAes, 'AES-GCM', false, ['decrypt']);
  return await crypto.subtle.decrypt({name:'AES-GCM', iv}, aesKey, ciphertext);
}

```

On a five-megabyte payload:

```
  payload: 5.0 MB
  encrypt: 67 ms | decrypt: 61 ms
  round-trip identical: true
  wrapped AES key: 256 bytes (one RSA op)
  AES-GCM overhead: 16 bytes (16-byte tag)
 
  RSA-OAEP alone maxes out at 190 bytes -- this handled 5,000,000.

```

**Five megabytes in 67 milliseconds, with exactly one RSA operation.** Recall Part 1's measurement that pure BigInt managed about fifty RSA decryptions per second — this scheme needs one per message regardless of size, and AES-GCM is hardware-accelerated on every CPU you'll deploy on.

The 16-byte overhead is GCM's authentication tag, and it earns its keep:

```
  tampered ciphertext rejected: OperationError (AES-GCM auth tag)

```

Flip one bit anywhere in five megabytes and decryption throws. That is authenticated encryption — you get confidentiality and integrity, and you cannot forget to check the second one because the API won't let you.

Three implementation notes. The **IV must be 12 bytes and must never repeat under the same key**; generating it fresh per message with `getRandomValues` is correct and reusing one is catastrophic. `importKey` with `extractable: false` means the unwrapped AES key cannot be read back out. And the AES key is generated per message, so compromise of one message's key reveals nothing about any other.

## A Limit Worth Knowing

While building the example above, this turned up:

```
DOMException [QuotaExceededError]: The requested length exceeds 65,536 bytes

```

**`crypto.getRandomValues()` will not fill a buffer larger than 65,536 bytes** in a single call. It is specified that way, in browsers and in Node, and it is the sort of thing you discover at the worst moment.

For anything larger, loop over subarrays:

```javascript
const buf = new Uint8Array(5_000_000);
for (let i = 0; i < buf.length; i += 65536) {
  crypto.getRandomValues(buf.subarray(i, Math.min(i + 65536, buf.length)));
}

```

You will rarely need megabytes of randomness — but if you are generating a large one-time pad, a test fixture, or padding, this is the wall.

## What Actually Carries Forward

The arithmetic from Part 1 — why `Number` fails, what `BigInt` fixes — is background. It is not what will break your application.

**What breaks applications is trust placed in the wrong direction.** Both JWT attacks above are the same error: a value the attacker controls was used to decide how to validate the attacker's input. Neither required breaking RSA. Neither required a timing measurement or a factoring machine. Both were one line.

That pattern is not specific to JWTs, and it's the thing worth carrying into whatever you build next. When you validate something, the *rules* must come from your configuration and the *data* from the request — and any place those two get mixed is where the vulnerability is.

The cryptography in this series is genuinely hard, and it is not where you will get hurt. You will get hurt in the twenty lines around it.