How to Use JWT Tokens in Rust (jsonwebtoken crate)

Use the `jsonwebtoken` crate to encode claims into a token and decode them back in your Axum handlers.

How to Use JWT Tokens in Rust

You're building an API. A user logs in with a password. You check the database, confirm the password, and now you need to tell the rest of your app, "This request comes from Alice." You don't want to hit the database for every single request. You also don't want to store session state on the server if you can avoid it. You sign a small packet of data with a secret key, hand it to the client, and ask the client to bring it back. When it returns, you verify the signature. If the signature matches, you trust the data inside. That packet is a JSON Web Token, or JWT.

The jsonwebtoken crate is the standard tool for this in Rust. It handles the encoding, signing, verification, and validation. You provide the claims and the secret; the crate ensures the token is structurally sound and cryptographically valid.

The anatomy of a token

A JWT is just a string with three parts separated by dots. The header describes the signing algorithm. The payload contains the claims, which are key-value pairs like user ID or expiration time. The signature proves the token hasn't been tampered with.

Think of a JWT like a letter sealed with wax. The letter has a header (how it's sealed), the payload (the message), and the signature (the wax stamp). Anyone can read the letter. The payload isn't encrypted by default. Base64 encoding makes it safe to transmit, but it doesn't hide the content. If someone tries to change the message, the wax breaks. You verify the wax. If the wax is intact, you know the message hasn't been tampered with. The jsonwebtoken crate handles the wax stamping and checking for you. You provide the secret; the crate provides the guarantee.

Minimal example

This example shows how to create a claims struct, encode a token, and decode it back. The code uses serde to serialize the claims into JSON.

use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};

/// Claims define the data inside the token.
/// serde derives serialization so the crate can turn this into JSON.
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    /// Subject identifier, usually a user ID or email.
    sub: String,
    /// Expiration time as a Unix timestamp in seconds.
    exp: u64,
}

fn main() {
    // Secret must be kept safe. In production, load from environment or vault.
    let secret = "super_secret_key_that_needs_to_be_longer_and_random";
    let encoding_key = EncodingKey::from_secret(secret.as_bytes());
    let decoding_key = DecodingKey::from_secret(secret.as_bytes());

    // Create claims with an expiration far in the future for this example.
    let claims = Claims {
        sub: "user_123".to_string(),
        exp: 9999999999,
    };

    // Encode creates the signed token string.
    // Header::default() uses HS256, which is HMAC-SHA256.
    let token = encode(&Header::default(), &claims, &encoding_key).unwrap();
    println!("Token: {}", token);

    // Decode verifies the signature and parses the claims.
    // Validation::default() requires 'exp' and checks the signature.
    let token_data = decode::<Claims>(&token, &decoding_key, &Validation::default()).unwrap();
    println!("User: {}", token_data.claims.sub);
}

Run this code. You'll see the token string and the decoded user. The token is just text, but the signature makes it trustworthy. If you modify the token string before decoding, the decode call returns an error.

What happens under the hood

When you call encode, the crate serializes your Claims struct into JSON. It base64url-encodes the header and the payload. Base64url is a variant of base64 that is safe for URLs and filenames. Then it computes a signature using HMAC-SHA256 over the encoded parts and your secret key. The result is three base64url strings joined by dots.

When you call decode, the process reverses. The crate splits the token by dots. It verifies the signature against the secret key. If the signature matches, it checks the validation rules. Validation::default() requires the exp claim and rejects tokens that are expired. It also checks the alg header to ensure it matches the expected algorithm. Finally, it deserializes the payload back into your struct.

If you forget to derive Deserialize on your claims struct, the compiler rejects you with E0277 (trait bound not satisfied). The crate needs to turn the JSON back into a struct, so Deserialize is mandatory. If you pass a token with a mismatched type for exp, you get E0308 (mismatched types) at compile time if you hardcode the type, or a JSON error at runtime if the token is malformed.

Realistic validation setup

In production, you need more control over validation. You should restrict the algorithm, require specific claims, and handle errors gracefully. The Validation struct lets you configure these rules.

use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};

/// Claims with a custom role field.
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    sub: String,
    exp: u64,
    role: String,
}

/// Verify a token and return the claims.
/// Returns an error if the token is invalid or expired.
fn verify_token(token: &str, secret: &[u8]) -> Result<Claims, jsonwebtoken::errors::Error> {
    // Validation must be configured. Default validation requires 'exp'.
    let mut validation = Validation::default();

    // Specify the algorithm. Hardcoding HS256 is safer than allowing any.
    // This prevents algorithm confusion attacks.
    validation.algorithms = vec![Algorithm::HS256];

    // Require 'exp' claim. This is already true for default, but explicit is better.
    validation.required_spec_claims = jsonwebtoken::Validation::required_spec_claims();

    // DecodingKey from secret bytes.
    let decoding_key = DecodingKey::from_secret(secret);

    // Decode returns a TokenData struct containing claims and header.
    let token_data = decode::<Claims>(token, &decoding_key, &validation)?;
    Ok(token_data.claims)
}

fn main() {
    let secret = "production_secret_key";
    let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."; // Truncated for brevity.

    match verify_token(token, secret.as_bytes()) {
        Ok(claims) => println!("Valid token for user: {}", claims.sub),
        Err(err) => eprintln!("Invalid token: {}", err),
    }
}

Always set the algorithm list. Trusting the header is how you get pwned. If you don't restrict algorithms, a malicious client can send a token with alg: none or switch from RSA to HMAC. The crate will accept it. Explicitly setting validation.algorithms closes this gap.

Pitfalls and traps

JWT expiration is a Unix timestamp in seconds. If you pass milliseconds, the token expires instantly. The jsonwebtoken crate expects seconds. If you pass std::time::SystemTime::now().elapsed().as_millis(), your token dies before the client receives it. Use as_secs() or divide milliseconds by 1000.

Another trap is the algorithm confusion attack. If you don't restrict the algorithm in Validation, a malicious client can send a token with alg: none or switch from RSA to HMAC. The crate will accept it. Always set validation.algorithms explicitly.

Secret management matters. The secret key must be long and random. A short secret is vulnerable to brute force attacks. Use at least 256 bits of entropy. In production, load the secret from an environment variable or a secrets vault. Never hardcode secrets in your source code.

Convention aside: EncodingKey::from_secret works for development, but EncodingKey::from_base64_secret is preferred in production. Base64 encoding avoids issues with special characters in secrets. If your secret contains characters that don't survive JSON or environment variable encoding, use base64.

Check your timestamps. A token that expires in zero seconds is a token that never works.

Decision matrix

Use jsonwebtoken when you need a battle-tested crate for JWT parsing, signing, and validation. It handles the cryptographic details and validation rules correctly.

Use a database-backed session when you need immediate revocation or fine-grained control over active sessions. JWTs are stateless. Once issued, they remain valid until expiration unless you implement a blacklist, which defeats the purpose of statelessness.

Use jsonwebtoken with custom claims when your application logic depends on data embedded in the token, like user roles or permissions. Embedding data in the token reduces database lookups.

Reach for jsonwebtoken::jwk when you are integrating with external identity providers that rotate public keys. JWK support handles key rotation and multiple keys automatically.

Avoid JWT for sensitive data. The payload is base64 encoded, not encrypted. Anyone with the token can read the claims. Don't put passwords, credit card numbers, or other secrets in a JWT.

JWT is stateless. Sessions are stateful. Pick the one that fits your threat model. Don't use JWT just because it's trendy.

Where to go next