JWT Validation in Rust Without the Usual Mistakes Explained
The mistakes that make JWT checks worthless: accepting the none algorithm, trusting the header's alg, skipping exp and aud, and mixing key types. Validate correctly with the jsonwebtoken crate.

A JWT looks like proof of identity, and it is only as good as the check on the server. Most JWT breaches are not broken cryptography. They are validation code that accepts a token it should have rejected: one signed with none, one expired last year, one meant for a different service. This post shows the correct call in Rust and each mistake by name. It belongs to the authentication posts of security for Rust developers, and its web side is in Axum middleware.
jsonwebtoken 9, Rust 1.90.
The correct check
[dependencies]
jsonwebtoken = "9"
serde = { version = "1", features = ["derive"] }use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
iss: String,
aud: String,
}
fn main() {
let secret = b"change-me-use-32-random-bytes!!!";
let claims = Claims {
sub: "user-7".into(),
exp: 4_102_444_800, // far future, for the example
iss: "https://auth.example.test".into(),
aud: "my-api".into(),
};
let token = encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(secret)).unwrap();
let mut validation = Validation::new(Algorithm::HS256);
validation.set_audience(&["my-api"]);
validation.set_issuer(&["https://auth.example.test"]);
validation.set_required_spec_claims(&["exp", "aud", "iss", "sub"]);
let data = decode::<Claims>(&token, &DecodingKey::from_secret(secret), &validation);
println!("{:?}", data.map(|d| d.claims.sub));
}It prints Ok("user-7"). The Validation fixes HS256, requires four claims, and checks audience and issuer. decode checks the signature and exp (with a default leeway of 60 seconds). Change the audience to other-api and the result is Err(InvalidAudience).
The mistakes
| Mistake | Why it is dangerous | Prevention |
|---|---|---|
Trusting the alg header | none, or HS256 with an RSA public key as secret | Validation::new(Algorithm::...) with a fixed list |
Skipping exp | stolen tokens work forever | required claims, short lifetime |
Not checking aud | a token for service A works on B | set the audience |
Not checking iss | tokens from any issuer with a key you trust | set the issuer |
| Decoding without verifying | forged claims | use decode, never dangerous_* for decisions |
| Long-lived access tokens | no revocation | 5 to 15 minutes plus refresh tokens |
| Sensitive data in the payload | payload is only base64 | keep PII out |
Asymmetric keys and rotation
When an identity provider signs with RSA or ECDSA, you verify with its public key, fetched from its JWKS endpoint. Read kid from the header only to select a key from your trusted set, never to fetch arbitrary key material:
let header = jsonwebtoken::decode_header(&token)?;
let key = keys.get(header.kid.as_deref().unwrap_or(""))
.ok_or("unknown key id")?;Cache the key set and refresh it on an unknown kid with a rate limit, so an attacker cannot force requests to your identity provider. Pin the algorithm to the key's type: RS256 keys with Algorithm::RS256.
In Axum
Write an extractor that reads Authorization: Bearer, calls decode, and returns 401 on failure. Do it once, as in extractors, and return the same generic error for every failure, so responses do not reveal which check failed.
Limits
The library validates tokens; it does not manage login, refresh tokens or revocation. The standard is RFC 8725, the JWT Best Current Practices, and the crate documentation is at docs.rs/jsonwebtoken. A decoder for inspecting tokens by hand is the JWT decoder tool.


