What valid means verifying JWTs in axum, in the order jsonwebtoken checks them
Allow-list the algorithm, verify the signature, then trust the claims; every rejection is a 401 with a Bearer challenge.
The problem
An API has two routes that matter here. POST /login takes a username and password. GET /me returns who the caller is. After login, every later request has to prove who is calling, and the server would like to do that without looking the user up in a session table on every call.
The obvious shortcuts are both broken. Suppose the client sends X-User-Id: ada and the server believes it. Anyone can then send X-User-Id: admin. Suppose instead the server hands out a token and later only decodes it, reading the contents without checking anything. Anyone can then write their own token with any name they like, because decoding needs no secret. In both cases the server accepts a claim about identity from the one party with a reason to lie, and the caller becomes whoever they say they are.
The server needs a string the client can carry but cannot forge, and that says when it stops being good. A JSON Web Token (JWT) signed with a key only the server knows is that string. Producing one is easy. The hard part is deciding, on every request, what counts as valid: which algorithm, which signature, which claims, which clock. The service below is small: axum 0.8.9 for HTTP, jsonwebtoken 11.1.0 for the tokens. It makes those decisions in the order the crate makes them, and every failure shown is real output from its tests.
The idea, in plain words
At login the server builds a small JSON object of claims: who the caller is, which API the token is for, and until when it is good. It signs that object with a secret key and gives the signed string to the client. The client sends the string back in an Authorization header on every request. The server recomputes the signature from the received contents with the same secret and compares it, then checks the claims. That is how it trusts the token without storing it anywhere. Change one character and the signature no longer matches.
Signed does not mean hidden. The header and payload are base64url text, not encrypted. In the lab run below, the header segment is eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9, which anyone can decode to {"typ":"JWT","alg":"HS256"}. The first characters of the payload segment, eyJzdWIiOiJhZGEiLCJpc3, decode to {"sub":"ada","is. Anyone who holds the token can read what is inside. So never put passwords, keys, or other secrets in claims.
Concept A: Claims and issuing a token
A JWT is three base64url-encoded parts joined by dots: header.payload.signature. The header names the algorithm, the payload holds the claims, and the signature is computed over the first two parts with the key. For HS256, the algorithm used here, that signature is a keyed hash (HMAC with SHA-256), so the same secret both creates and checks it. The lab prints token has 3 dot-separated parts for a freshly issued token.
A claim is one name and value pair in the payload. RFC 7519 defines several names with fixed meanings, and the lab uses five:
sub(subject) identifies the principal the token is about. Here it is the username.iss(issuer) identifies the principal that issued the token. Here it is the auth service's URL.aud(audience) "identifies the recipients that the JWT is intended for". Each principal that processes the token must identify itself with a value inaud. If it does not, andaudis present, "the JWT MUST be rejected".iat(issued at) is the time the token was issued.exp(expiration time) is the time "on or after which the JWT MUST NOT be accepted for processing".
The RFC makes all of these optional, so your application decides which it requires. The reasons are practical. Without aud, a token minted for notes-api would also open billing-api, because both trust the same signer. Without exp, a stolen token would work forever.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String,
pub iss: String,
pub aud: String,
pub iat: u64,
pub exp: u64,
}
pub fn issue(keys: &Keys, sub: &str, ttl_secs: i64) -> String {
let iat = now();
let exp = (iat as i64 + ttl_secs) as u64;
let claims = Claims { sub: sub.into(), iss: ISSUER.into(), aud: AUDIENCE.into(), iat, exp };
encode(&Header::new(Algorithm::HS256), &claims, &keys.encoding).expect("encode")
}
Claims is the shape of the payload, and serde turns it to and from JSON. issue reads the clock once, computes exp as issue time plus a time to live in seconds, and fills in the issuer and audience constants. It then calls encode with a header created by Header::new(Algorithm::HS256) and an EncodingKey built from the secret with EncodingKey::from_secret. You get back one string. The login route calls issue(&state.keys, "ada", 900), so the token lives 15 minutes. The lab's /me response shows iat 1792418133 and exp 1792419033, which differ by exactly 900.
The secret needs care. RFC 8725 section 3.5 says "human-memorizable passwords MUST NOT be directly used as the key to a keyed-MAC algorithm such as "HS256"". The lab's literal secret, lab-secret-at-least-32-bytes-long!!, is for the lab only. A real service loads a long random key from configuration.
Common mistake: no crypto backend feature. Symptom: the program compiles, then the first sign or verify call panics. Cause: jsonwebtoken 11 needs exactly one backend feature, rust_crypto or aws_lc_rs. The README says "at most one of which must be enabled", and with neither you must supply your own CryptoProvider. The crate source then panics with "Could not automatically determine the process-level CryptoProvider from jsonwebtoken crate features." Fix: enable one backend in Cargo.toml, as the lab does with rust_crypto. The lab did not run the no-feature case. That panic message and its cause are read from the crate source.
Concept B: Validation and the order of checks
Validation is the set of rules that decode applies before it gives you claims. jsonwebtoken keeps them in a Validation value. If you skip it, a token that merely parses looks the same as one you should trust. The lab builds one in Keys::new:
impl Keys {
pub fn new(secret: &[u8]) -> Self {
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&[ISSUER]);
validation.set_audience(&[AUDIENCE]);
validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
validation.leeway = 5;
Keys {
encoding: EncodingKey::from_secret(secret),
decoding: DecodingKey::from_secret(secret),
validation,
}
}
}
Validation::new(Algorithm::HS256) starts from the crate defaults. The source sets required_spec_claims to {"exp"}, leeway to 60 seconds, validate_exp to true, validate_nbf to false and validate_aud to true. It also allows exactly the one algorithm you pass. The lab then expects one issuer and one audience, requires four claims to be present, and cuts the leeway to 5 seconds. The docs add that set_required_spec_claims only considers "exp", "nbf", "aud", "iss" and "sub". iat is not on that list, so the lab's Claims struct enforces it through its non-optional iat: u64 field.
One detail in the source is easy to miss: set_required_spec_claims replaces the set, it does not add to it. The lab checks this directly. Under the default rules a token with no exp fails with MissingRequiredClaim("exp"). With the required set changed to ["sub"], the same token is accepted and the test prints exp = None. A token with no expiry would then never expire. That is why the lab's list repeats "exp" explicitly.
Leeway exists because two clocks never agree exactly. RFC 7519 section 4.1.4 says implementers "MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew". The lab keeps it small. Its test issues a token with a TTL of -60, so exp is 60 seconds in the past, and the response is ExpiredSignature. That is well outside a 5-second leeway.
Now the order. This is the whole decode function from the crate source:
pub fn decode<T: DeserializeOwned>(
token: impl AsRef<[u8]>,
key: &DecodingKey,
validation: &Validation,
) -> Result<TokenData<T>> {
let token = token.as_ref();
let header = decode_header(token)?;
if !validation.algorithms.contains(&header.alg) {
return Err(new_error(ErrorKind::InvalidAlgorithm));
}
let verifying_provider = (CryptoProvider::get_default().verifier_factory)(&header.alg, key)?;
let (header, claims) = verify_signature(token, validation, verifying_provider)?;
let decoded_claims = DecodedJwtPartClaims::from_jwt_part_claims(claims)?;
let claims = decoded_claims.deserialize()?;
validate(decoded_claims.deserialize()?, validation)?;
Ok(TokenData { header, claims })
}
Every ? is an early exit, so the failures come in a fixed sequence:
decode_headerparses the header. A string that is not a JWT stops here.- The algorithm allow-list runs. The
algfield is text the caller wrote, so the crate checks it againstvalidation.algorithmsbefore any cryptography and returnsInvalidAlgorithmif it is not listed. RFC 8725 section 3.1 requires this: libraries "MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms". Otherwise the token would choose how it is verified. The lab's test signs a token with HS384 using the correct secret and still getsInvalidAlgorithm. - The signature is verified, and a mismatch gives
InvalidSignature. A token signed with a different secret fails here, and so does the lab's tampered token. Nothing in the payload is trusted before this passes, so a forged token never reaches the claim checks. - The claims are deserialized into your type
T. This is thelet claims = decoded_claims.deserialize()?;line, line 287 ofsrc/decoding.rs. validate, on the next line (288), runs the claim checks:exp,nbf,aud,iss, and the required-claims set. The wrong-audience test getsInvalidAudiencefrom this step.
Two lab tests show why steps 4 and 5 are separate. A token with no sub, decoded into the strict Claims struct, fails with a serde error containing `missing field sub , as the lab test asserts. The deserialize line cannot build a Claims without a sub, so validate never runs. The same token decoded into a struct with sub: Option<String> deserializes fine, reaches the checks, and the lab prints loose decode error kind: MissingRequiredClaim("sub")`. The token is rejected both times. Only the error kind differs.
**Common mistake: expecting MissingRequiredClaim.** Symptom: your logs show Json(Error("missing field ...")) where you expected MissingRequiredClaim. Cause: a non-Option field in your claims type, so serde fails at the deserialize step before validate sees the token. Fix: accept it, since the request is still rejected. If you want the crate's error kind, make the field Option, and keep it in set_required_spec_claims so the check still demands it.
Concept C: The extractor and the 401
An extractor is a type that axum builds from the incoming request and passes to your handler as an argument. FromRequestParts is the axum trait for extractors that read only the request head: method, URI and headers, not the body. That matters because the body can be consumed once. Per the axum docs, an extractor implementing FromRequestParts can sit in any argument position, while one implementing FromRequest, such as Json, must be last. Because AuthUser only reads headers, it can appear before Json in a handler. The trait declares type Rejection: IntoResponse and a from_request_parts(parts, state) method that returns a future. In axum 0.8 you write that as a plain async fn, and the lab compiles that way with no async_trait.
pub struct AuthUser(pub Claims);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let value = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AuthError::Missing)?;
let token = value.strip_prefix("Bearer ").ok_or(AuthError::Missing)?;
let data = decode::<Claims>(token, &state.keys.decoding, &state.keys.validation)
.map_err(|e| AuthError::Invalid(e.into_kind()))?;
Ok(AuthUser(data.claims))
}
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let detail = match &self {
AuthError::Missing => "missing bearer token".to_string(),
AuthError::Invalid(k) => format!("invalid token: {k:?}"),
};
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer")],
Json(serde_json::json!({ "error": detail })),
)
.into_response()
}
}
The extractor reads the Authorization header, turns it into a string, and strips the exact prefix Bearer . A missing header, a non-text header, or a different prefix all become AuthError::Missing. The rest goes to decode::<Claims> with the keys and rules from AppState, and any failure is wrapped as AuthError::Invalid with the crate's error kind. On success the handler receives the claims inside AuthUser. AuthError is the rejection type, so axum calls into_response on it and sends a 401 with a WWW-Authenticate: Bearer header and a JSON body.
This gives the lab its protection model. async fn me(AuthUser(claims): AuthUser) names the extractor in its arguments, so every request to /me goes through the checks above. health names nothing, so /health is public. You protect a route by adding one argument, and there is no separate list of protected paths to keep in step with the router.
The status and header follow RFC 6750. Section 3 says that if the request "does not include authentication credentials or does not contain an access token that enables access", the server "MUST include the HTTP "WWW-Authenticate" response header field", and that all challenges "MUST use the auth-scheme value "Bearer"". Section 3.1 describes the invalid_token error as a token that is "expired, revoked, malformed, or invalid for other reasons", and says the resource "SHOULD respond with the HTTP 401 (Unauthorized) status code". The lab's challenge is a bare Bearer, and the reason goes in the JSON body. RFC 6750 also defines error="invalid_token" parameters that a real server would add to the header. Sending the internal error kind to clients is a lab convenience, and a real API may prefer to hide it.
Common mistake: a prefix mismatch. Symptom: every protected request returns 401 missing bearer token, even with a valid token. Cause: the client sent bearer in lowercase or Token ..., and the lab matches the exact prefix Bearer . Fix: send Authorization: Bearer <token>, as RFC 6750 section 2.1 describes, or relax the prefix match on purpose.
Putting it together
Here is the cargo run output, mapped to the check that decided each line:
| Request | Result | What decided it |
|---|---|---|
POST /login with a wrong password | 401 bad credentials | Login route, no token issued |
POST /login with ada | 200 with access_token | issue signs HS256 claims, 900 s |
GET /me, no header | 401 missing bearer token | Extractor found no Bearer header |
GET /me with the token | 200 and the claims | Allow-list, signature and claims all passed |
GET /me, last signature character changed | 401 InvalidSignature | Signature check, before any claim is read |
This answers the problem. The client cannot pick its own identity, because changing the payload breaks the signature, and the server never consulted a session table. The tests in tests/jwt.rs cover the rest: a valid token reaches the handler, a missing header gets 401 with the challenge, a wrong secret gives InvalidSignature, a token expired 60 s ago gives ExpiredSignature, a wrong audience gives InvalidAudience, an HS384 token gives InvalidAlgorithm, and the two missing-sub cases show the deserialize-then-validate order, and a ninth test shows that set_required_spec_claims replaces the default exp requirement.
The limits are real, and none of them was run in the lab:
- No refresh tokens. When the token expires, the user logs in again.
- No revocation. A signed token stays valid until
exp, which is why the lifetime here is a short 900 seconds. - No password hashing. The login accepts one hard-coded user, and a real service checks a password hash.
- Shared secret. With HS256, anyone who can verify can also mint tokens. Asymmetric algorithms such as RS256 or EdDSA with a public
DecodingKeylet verifiers work without holding the signing key.
How to run it
Tested with rustc 1.99.0, axum 0.8.9, jsonwebtoken 11.1.0. The dependency line:
jsonwebtoken = { version = "=11.1.0", default-features = false, features = ["rust_crypto"] }
Then run:
cargo test
cargo run
The result line below came from cargo test --test jwt -- --nocapture --test-threads=1, which runs only the integration file and prints each test's output next to its name:
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Check yourself
- Why can anyone read the claims in a signed JWT, and what follows for what you put in them? The header and payload are base64url text, not encrypted. Never put secrets in claims.
- A token signed with HS384 using the correct secret arrives at a server whose
Validationallows only HS256. Which error comes back, and which step produced it? *InvalidAlgorithm, from the allow-list check, which runs before signature verification.* - Your claims struct has
sub: Stringand a token arrives withoutsub. Which error appears, and why notMissingRequiredClaim? *A serdemissing fielderror.decodedeserializes into your type before it runs claim validation, so the missing field fails first.* - You call
set_required_spec_claims(&["sub"])and nothing else. Isexpstill required? *No. The call replaces the default set, so a token withoutexpis accepted and never expires. List"exp"yourself.* - Why can a handler like
/healthbe public while/meis protected? *Only handlers that nameAuthUserin their arguments run the extractor, so/healthnever checks a token.*
Sources
- RFC 7519, JSON Web Token (JWT), sections 4.1.1 (iss), 4.1.2 (sub), 4.1.3 (aud), 4.1.4 (exp), 4.1.6 (iat): https://www.rfc-editor.org/rfc/rfc7519
- RFC 8725, JSON Web Token Best Current Practices, sections 3.1 and 3.5: https://www.rfc-editor.org/rfc/rfc8725
- RFC 6750, The OAuth 2.0 Authorization Framework: Bearer Token Usage, sections 2.1, 3 and 3.1: https://www.rfc-editor.org/rfc/rfc6750
- axum 0.8.9,
extractmodule documentation: https://docs.rs/axum/0.8.9/axum/extract/index.html - axum 0.8.9,
FromRequestPartstrait: https://docs.rs/axum/0.8.9/axum/extract/trait.FromRequestParts.html - jsonwebtoken 11.1.0 API documentation (
encode,decode,Validation,ErrorKind): https://docs.rs/jsonwebtoken/11.1.0/jsonwebtoken/ - jsonwebtoken 11.1.0 source and README (
src/validation.rs,src/crypto/mod.rs,src/decoding.rs): https://github.com/Keats/jsonwebtoken