Zero-Dependency JWT Decoding in Rust
Rust does not have Base64URL in std, but base64 and serde_json are so foundational they might as well be. This decodes a JWT payload with only those two crates — no jsonwebtoken.
// Cargo.toml
// base64 = "0.22"
// serde_json = "1"
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde_json::Value;
pub fn decode_payload(token: &str) -> Result<Value, Box<dyn std::error::Error>> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err("Not a JWT".into());
}
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])?;
let payload: Value = serde_json::from_slice(&payload_bytes)?;
Ok(payload)
}
// Usage
fn main() {
let claims = decode_payload("eyJhbGciOi...").unwrap();
println!("sub: {}", claims["sub"]);
println!("exp: {}", claims["exp"]);
}For logging, debugging, developer tools. Never for authorization.
Using jsonwebtoken — The Rust Standard
By Keats, the same person who maintains the Tera template engine. 1.5k+ stars, actively developed, safe defaults.
Installation
# Cargo.toml
[dependencies]
jsonwebtoken = "9"
serde = { version = "1", features = ["derive"] }
serde_json = "1"Verify with HS256 Secret
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
iat: usize,
iss: String,
aud: String,
// Custom claim
#[serde(default)]
roles: Vec<String>,
}
fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&["https://auth.example.com"]);
validation.set_audience(&["https://api.example.com"]);
validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
validation.leeway = 30; // Clock drift tolerance in seconds
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)?;
Ok(token_data.claims)
}Verify with RS256 (Public Key from PEM)
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
fn verify_rs256(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let pub_pem = std::fs::read("public.pem")?;
let key = DecodingKey::from_rsa_pem(&pub_pem)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&["https://auth.example.com"]);
validation.set_audience(&["https://api.example.com"]);
let data = decode::<Claims>(token, &key, &validation)?;
Ok(data.claims)
}Verify with JWKS (Auth0, Cognito, Azure AD)
// Cargo.toml
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// dashmap = "6"
use dashmap::DashMap;
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation, Algorithm};
use serde::Deserialize;
use std::sync::Arc;
#[derive(Deserialize)]
struct Jwks { keys: Vec<Jwk> }
#[derive(Deserialize)]
struct Jwk { kid: String, n: String, e: String, kty: String, #[serde(rename = "alg")] alg: String }
pub struct JwksCache {
keys: Arc<DashMap<String, DecodingKey>>,
url: String,
}
impl JwksCache {
pub async fn refresh(&self) -> Result<(), reqwest::Error> {
let jwks: Jwks = reqwest::get(&self.url).await?.json().await?;
for k in jwks.keys {
let key = DecodingKey::from_rsa_components(&k.n, &k.e).unwrap();
self.keys.insert(k.kid, key);
}
Ok(())
}
pub async fn verify(&self, token: &str) -> Result<Claims, Box<dyn std::error::Error>> {
let header = decode_header(token)?;
let kid = header.kid.ok_or("no kid")?;
// Refresh on miss (handles key rotation)
if !self.keys.contains_key(&kid) {
self.refresh().await?;
}
let key = self.keys.get(&kid).ok_or("unknown kid")?;
let mut v = Validation::new(Algorithm::RS256);
v.set_issuer(&["https://auth.example.com"]);
let data = decode::<Claims>(token, &key, &v)?;
Ok(data.claims)
}
}Actix-Web Middleware (via FromRequest)
// Cargo.toml
// actix-web = "4"
use actix_web::{FromRequest, HttpRequest, dev::Payload, Error, error::ErrorUnauthorized};
use std::future::{Ready, ready};
impl FromRequest for Claims {
type Error = Error;
type Future = Ready<Result<Self, Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let auth = req.headers().get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "));
let token = match auth {
Some(t) => t,
None => return ready(Err(ErrorUnauthorized("Missing token"))),
};
let secret = std::env::var("JWT_SECRET").unwrap();
match verify_token(token, &secret) {
Ok(claims) => ready(Ok(claims)),
Err(_) => ready(Err(ErrorUnauthorized("Invalid token"))),
}
}
}
// Usage in handler
async fn me(claims: Claims) -> impl Responder {
HttpResponse::Ok().json(claims)
}Axum Middleware (via from_fn)
use axum::{
http::{Request, StatusCode, header::AUTHORIZATION},
middleware::Next,
response::Response,
body::Body,
};
pub async fn jwt_middleware(
mut req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
let token = req.headers()
.get(AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
let secret = std::env::var("JWT_SECRET").unwrap();
let claims = verify_token(token, &secret)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
req.extensions_mut().insert(claims);
Ok(next.run(req).await)
}
// Router
use axum::{Router, routing::get, middleware};
let app = Router::new()
.route("/me", get(me_handler))
.layer(middleware::from_fn(jwt_middleware));Common Rust JWT Errors
- ErrorKind::ExpiredSignature — exp claim is in the past. Client must refresh.
- ErrorKind::ImmatureSignature — nbf is in the future. Clock drift on issuer.
- ErrorKind::InvalidSignature — signature does not match the DecodingKey.
- ErrorKind::InvalidAlgorithm — token alg does not match Validation.algorithms. Alg confusion defence.
- ErrorKind::InvalidIssuer / InvalidAudience — iss or aud does not match validation.set_issuer / set_audience.
- ErrorKind::MissingRequiredClaim(name) — a claim in required_spec_claims is absent from the token payload.
Security Best Practices for Rust JWT
- Never construct
Validation::default()without settingalgorithms. Always useValidation::new(Algorithm::HS256)or set the algorithms vector explicitly. - Always call
validation.set_issuerandvalidation.set_audience. Do not accept any iss/aud. - HS256 secret must be at least 32 random bytes. Generate with
rand::thread_rng().fill()and store in env, never in source. - For JWKS, refresh the cache on any kid miss — that is how you handle key rotation transparently.
- Set
validation.leeway = 30for clock drift. Do not exceed 60 seconds. - Use typed Claims structs (not
serde_json::Value) — the compile-time check prevents typo bugs and makes claim access ergonomic.
Key Facts
- Standard crate:
- jsonwebtoken = "9" (Keats/jsonwebtoken)
- Alt crate:
- jwt-simple by jedisct1 — compile-time alg safety
- HS256 secret:
- 32+ random bytes. Store in env, never in source.
- JWKS:
- jwks-client or a small DashMap cache with reqwest
- MSRV:
- Rust 1.65 for jsonwebtoken 9.x
Related JWT Tools
- JWT Decoder Online — decode any token instantly
- JWT Decoder Go — golang-jwt/jwt v5 and Gin/Fiber
- JWT Decoder Python — PyJWT for Flask/Django/FastAPI
- JWT Decoder Node.js — jsonwebtoken and jose patterns
- JWT Decoder Java — jjwt and Spring Boot