Skip to content

perf(keys): cache parsed RSA key pair instead of re-parsing per encode (#120) - #126

Merged
ZhuchkaTriplesix merged 2 commits into
devfrom
issue/120-cached-parsed-keys
Sep 28, 2026
Merged

ZhuchkaTriplesix merged 2 commits into
devfrom
issue/120-cached-parsed-keys

Conversation

@ZhuchkaTriplesix

@ZhuchkaTriplesix ZhuchkaTriplesix commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Problem

jsonwebtoken::crypto::sign re-parses (and fully re-validates) the RSA private key into an aws_lc_rs::signature::RsaKeyPair on every encode call, regardless of whether the caller passed a pre-built EncodingKey. That re-parse/validation was the dominant cost of RSA signing:

µs/call
OxyJWT RS256 encode (before) 526
cryptography RSA-2048 sign, pre-parsed key 198

Fix

EncodingKey.from_rsa_pem now parses the DER-encoded key into an RsaKeyPair once, at construction time, and encode/encode_json sign through that cached key directly via aws_lc_rs, bypassing jsonwebtoken::crypto::sign for the RSA family (RS*/PS*). Every other algorithm (HMAC, EC, EdDSA) and all decode/verify paths are untouched — a follow-up measurement showed they were already close to their floor (e.g. ES256 encode 21µs vs. 19µs raw cryptography sign, EdDSA encode 13µs vs. 26µs), so caching there would add risk for negligible gain. That's a narrower scope than #120's title suggested; the rest of that investigation is written up in the issue and CHANGELOG entry.

Only active with the default aws_lc_rs crypto backend (#[cfg(feature = "aws_lc_rs")]); the rust_crypto feature used for the Linux aarch64 wheel is unchanged and still goes through jsonwebtoken::crypto::sign.

Behavior change: a malformed RSA private key is now rejected by EncodingKey.from_rsa_pem itself instead of lazily by the first encode() call. No test relied on the old lazy timing.

Results

Pre-built EncodingKey, 2048-bit RSA key, release build:

before after vs. raw cryptography sign
RS256 encode 526 µs 201 µs (2.6×) 210 µs
RS256 decode 21 µs 16 µs — (unaffected path)

scripts/compare_jwt_libraries.py --algorithms RS256,PS256,HS256,ES256,EdDSA (this branch): RS256 encode ~5,000 ops/sec, PS256 encode ~5,100 ops/sec — consistent with the µs numbers above.

Checklist

  • EncodingKey.from_rsa_pem parses the key once; encode/encode_json do no per-call RSA key parsing
  • RS256 encode with a prebuilt EncodingKey within ~10% of raw cryptography sign (measured ~5% over)
  • Both aws_lc_rs and rust_crypto features build (cargo build/clippy with and without --no-default-features --features rust_crypto)
  • cargo fmt --check, cargo clippy --all-targets -- -D warnings clean on both feature sets
  • cargo test (11/11) and pytest (258 passed, 1 skipped) green
  • mypy clean
  • Before/after numbers in this PR and in CHANGELOG.md

Closes #120.

jsonwebtoken::crypto::sign re-parses (and fully re-validates) the RSA
private key into an aws_lc_rs::signature::RsaKeyPair on every RS*/PS*
encode call, which was the dominant cost of RSA signing: RS256 encode
measured ~526us with a pre-built EncodingKey, ~2.7x the ~198us a raw
cryptography sign takes with an equivalent pre-parsed key.

EncodingKey.from_rsa_pem now parses the DER-encoded key into an
RsaKeyPair once, at construction time, and encode()/encode_json() sign
through that cached key directly via aws_lc_rs, bypassing
jsonwebtoken::crypto::sign for the RSA family. Every other algorithm
(HMAC, EC, EdDSA) and the decode/verify paths are unchanged; they were
already close to their measured floor.

Only active with the default aws_lc_rs crypto backend (behind
`#[cfg(feature = "aws_lc_rs")]`); the rust_crypto feature used for the
Linux aarch64 wheel keeps the previous jsonwebtoken-only path.

A malformed RSA private key is now rejected by EncodingKey.from_rsa_pem
itself instead of lazily by the first encode() call.

Measured (2048-bit key, pre-built EncodingKey): RS256 encode
526us -> 201us (2.6x), now within ~5% of raw cryptography sign (210us).
decode and all non-RSA algorithms are unaffected.

Closes #120
Add coverage for the cached-key path introduced in the previous
commit: repeated encode() calls on the same RSA EncodingKey (RS*/PS*)
must keep producing signatures that verify correctly, not just on the
first call, and a malformed RSA PEM must fail at
EncodingKey.from_rsa_pem construction rather than lazily at encode().
@ZhuchkaTriplesix
ZhuchkaTriplesix force-pushed the issue/120-cached-parsed-keys branch from 158152f to 234d2dd Compare September 28, 2026 10:08

@ZhuchkaTriplesix ZhuchkaTriplesix left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

123

@ZhuchkaTriplesix
ZhuchkaTriplesix merged commit fae5bda into dev Sep 28, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant