Hedronite · Backend Dev · Rust · Mon 2026-10-19

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.

Lesson Class: Dev (Rust backend · axum API auth)
Tested with: axum 0.8.9 · jsonwebtoken 11.1.0 · rustc/cargo 1.99.0
Lab: crate/ · cargo test (9) · cargo run
Source: RFC 7519 · RFC 8725 · RFC 6750 · jsonwebtoken source
Signed Is Not Hidden
Three base64url parts anyone can read; pick exactly one crypto backend or the first verify panics.
The Order Is the Rule
Algorithm allow-list, signature, deserialize, then claims; set_required_spec_claims replaces the default exp.
One Argument Guards a Route
AuthUser via FromRequestParts; rejection is 401 with WWW-Authenticate: Bearer.
Check the key before the claim.

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:

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:

  1. decode_header parses the header. A string that is not a JWT stops here.
  2. The algorithm allow-list runs. The alg field is text the caller wrote, so the crate checks it against validation.algorithms before any cryptography and returns InvalidAlgorithm if 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 gets InvalidAlgorithm.
  3. 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.
  4. The claims are deserialized into your type T. This is the let claims = decoded_claims.deserialize()?; line, line 287 of src/decoding.rs.
  5. validate, on the next line (288), runs the claim checks: exp, nbf, aud, iss, and the required-claims set. The wrong-audience test gets InvalidAudience from 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:

RequestResultWhat decided it
POST /login with a wrong password401 bad credentialsLogin route, no token issued
POST /login with ada200 with access_tokenissue signs HS256 claims, 900 s
GET /me, no header401 missing bearer tokenExtractor found no Bearer header
GET /me with the token200 and the claimsAllow-list, signature and claims all passed
GET /me, last signature character changed401 InvalidSignatureSignature 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:

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

  1. 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.
  2. A token signed with HS384 using the correct secret arrives at a server whose Validation allows only HS256. Which error comes back, and which step produced it? *InvalidAlgorithm, from the allow-list check, which runs before signature verification.*
  3. Your claims struct has sub: String and a token arrives without sub. Which error appears, and why not MissingRequiredClaim? *A serde missing field error. decode deserializes into your type before it runs claim validation, so the missing field fails first.*
  4. You call set_required_spec_claims(&["sub"]) and nothing else. Is exp still required? *No. The call replaces the default set, so a token without exp is accepted and never expires. List "exp" yourself.*
  5. Why can a handler like /health be public while /me is protected? *Only handlers that name AuthUser in their arguments run the extractor, so /health never checks a token.*

Sources