Skip to content
ecdsa.com

Verify an ES256 JWT · Rust

How to verify an ES256 JWT in Rust

To verify an ES256 JWT in Rust, use the jsonwebtoken crate: DecodingKey::from_ec_pem loads the public key, a Validation built for Algorithm::ES256 pins the algorithm, and decode checks the signature and the claims into your own serde struct. Version 11 makes you pick the cryptography backend — pure-Rust RustCrypto or aws-lc-rs — with a Cargo feature.

Tested with jsonwebtoken (Rust) 11.1.0 (Rust 1.99.1, rust_crypto backend (p256 0.13)).

Cargo.toml — jsonwebtoken with the RustCrypto backend
[dependencies]
jsonwebtoken = { version = "=11.1.0", features = ["rust_crypto"] } # or "aws_lc_rs"
serde = { version = "1", features = ["derive"] }
src/main.rs — ES256 pinned, issuer required, lifetime checked
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Claims {
sub: String,
exp: u64,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let token = std::fs::read_to_string("token.txt")?;
let key = DecodingKey::from_ec_pem(&std::fs::read("pub.pem")?)?; // SPKI "BEGIN PUBLIC KEY"
let mut validation = Validation::new(Algorithm::ES256); // the only algorithm accepted
validation.set_issuer(&["https://ecdsa.com"]); // checked only if the token has an iss…
validation.set_required_spec_claims(&["exp", "iss", "sub"]); // …so require it as well
validation.validate_nbf = true; // off by default
validation.leeway = 30; // seconds of clock skew; the default is 60
let data = decode::<Claims>(token.trim(), &key, &validation)?;
println!("valid ES256 token for {}, exp {}", data.claims.sub, data.claims.exp);
Ok(())
}

How it works

  1. DecodingKey::from_ec_pem reads the SPKI BEGIN PUBLIC KEY block; for keys from a JWKS endpoint use DecodingKey::from_jwk instead.
  2. Validation::new(Algorithm::ES256) accepts that one algorithm only: a token whose header says HS256 fails with InvalidAlgorithm before any signature work.
  3. decode::<Claims> verifies the 64-byte r‖s signature over the header.payload text, then deserializes the payload into your struct — a missing or mistyped field is an error, not a silent default.
  4. Errors carry a kind: the run produces InvalidSignature, InvalidAlgorithm, ExpiredSignature and MissingRequiredClaim("iss") for a swapped payload, a relabelled header, an expired token and a token without an issuer.

Gotchas

  • set_issuer (and set_audience) only compare the claim when the token has one. A token without iss passes unless "iss" is in set_required_spec_claims — the default requires only exp.
  • nbf is not checked unless you set validate_nbf = true, and exp gets 60 seconds of leeway by default.
  • validate_aud is on by default: a token that carries an aud claim fails with InvalidAudience until you call set_audience with an expected value.
  • Without exactly one of the rust_crypto or aws_lc_rs features, jsonwebtoken 11 compiles but panics at the first verification with Could not automatically determine the process-level CryptoProvider.
  • jsonwebtoken::dangerous::insecure_decode and insecure_decode_claims skip the signature and all claim checks. They exist for debugging; nothing read through them may be trusted.

Related recipes