RSA in JavaScript Part 2: The JWT Is Where You'll Actually Meet It

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.

Part 1 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.

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

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.

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.

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

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:

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.

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:

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.