When building backend APIs, JWT authentication is a common requirement. In Rust, you’ve got several web frameworks to choose from, and Rocket is one that makes request handling feel natural with its request guard system. Combining Rocket with JWTiny for JWT validation and JWKServe as a local identity provider gives you a complete setup for development and testing without external dependencies.
In this guide, I’ll walk you through building a Rust API from scratch: setting up a Rocket application, adding JWT authentication to protect a /profile endpoint, and using JWKServe to generate tokens with different payloads for testing. By the end, you’ll have a working API that validates JWT tokens and extracts claims like the sub (subject) identifier.
Project Setup
Let’s start by creating a new Rust project:
cargo new rocket-jwt-api --bin
cd rocket-jwt-api
Next, add the necessary dependencies to your Cargo.toml:
[package]
name = "rocket-jwt-api"
version = "0.1.0"
edition = "2021"
[dependencies]
rocket = "0.5.1"
jwtiny = "1.6.2"
reqwest = { version = "0.12", features = ["rustls-tls"] }
moka = { version = "0.12", features = ["future"] }
tokio = { version = "1", features = ["full"] }
miniserde = "0.1.43"
jwtype = "1.6.2"
The key dependencies here are:
rocket: The web frameworkjwtiny: JWT validation library with JWKS support (uses miniserde internally)reqwest: HTTP client for fetching JWKS keysmoka: Caching library for key cachingtokio: Async runtimeminiserde: Minimal serialisation/deserialisation for JSON
Application Structure
We’ll organise the code into a few modules. Create the following structure:
mkdir -p src
The main application will live in src/main.rs, and we’ll build it step by step.
Building the Rocket Application
Let’s start with a basic Rocket application that has a public endpoint and a protected /profile endpoint. Since we’re using miniserde instead of serde, we’ll create a custom JSON responder:
use miniserde::json;
use rocket::http::{ContentType, Status};
use rocket::response::{Responder, Response};
use rocket::{get, launch, routes, Request};
#[derive(miniserde::Serialize)]
struct ProfileResponse {
subject: String,
message: String,
}
struct JsonResponse(String);
impl<'r> Responder<'r, 'static> for JsonResponse {
fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'static> {
Response::build()
.status(Status::Ok)
.header(ContentType::JSON)
.sized_body(self.0.len(), std::io::Cursor::new(self.0))
.ok()
}
}
#[get("/")]
fn index() -> &'static str {
"Hello, World! API is running."
}
#[get("/profile")]
fn profile() -> JsonResponse {
// This will be protected with JWT authentication
let response = ProfileResponse {
subject: "user-12345".to_string(),
message: "Profile endpoint".to_string(),
};
JsonResponse(json::to_string(&response))
}
#[launch]
fn rocket() -> _ {
rocket::build()
.mount("/", routes![index, profile])
}
Run this with cargo run, and you should see the server start on port 8000. You can test it:
curl http://localhost:8000/
# Hello, World! API is running.
curl http://localhost:8000/profile
# {"subject":"user-12345","message":"Profile endpoint"}
Setting Up JWT Validation
Now we need to add JWT validation. First, let’s set up the validator with JWKS support. We’ll configure it to work with JWKServe, which we’ll run locally.
The validator needs to be shared across requests, so we’ll store it in Rocket’s state:
use jwtiny::{AlgorithmPolicy, ClaimsValidation, RemoteCacheKey, TokenValidator};
use moka::future::Cache;
use reqwest::Client;
use std::{sync::Arc, time::Duration};
#[derive(Clone)]
struct AppState {
validator: TokenValidator,
}
fn create_validator() -> TokenValidator {
let client = Client::new();
let cache = Arc::new(
Cache::<RemoteCacheKey, Vec<u8>>::builder()
.time_to_live(Duration::from_secs(300))
.max_capacity(1000)
.build(),
);
TokenValidator::new()
.algorithms(AlgorithmPolicy::rsa_all())
.issuer(|iss| iss == "http://127.0.0.1:3000")
.validate(ClaimsValidation::default())
.jwks(client)
.cache(cache)
.build()
}
The validator is configured to:
- Accept all RSA algorithms (RS256, RS384, RS512)
- Validate that the issuer is
http://127.0.0.1:3000(JWKServe’s default) - Use JWKS for key fetching with caching
Creating the Authentication Guard
Rocket’s request guard system makes authentication feel native. We’ll create an Authenticated type that implements FromRequest. First, let’s define a custom claims structure:
use jwtiny::claims;
#[claims]
pub struct CustomClaims {
#[serde(rename = "role")]
pub role: Option<String>,
}
The #[claims] macro handles the standard claims (iss, sub, aud, exp, nbf, iat, jti) automatically, so you only need to define your custom fields. Now let’s create the authentication guard:
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome, Request};
pub struct Authenticated(pub CustomClaims);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Authenticated {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let state = match request.rocket().state::<AppState>() {
Some(s) => s,
None => return Outcome::Error((Status::InternalServerError, ())),
};
let auth_header = match request.headers().get_one("authorization") {
Some(h) => h,
None => return Outcome::Error((Status::Unauthorized, ())),
};
let token_str = match auth_header.split_whitespace().nth(1) {
Some(t) => t,
None => return Outcome::Error((Status::BadRequest, ())),
};
let claims = match state
.validator
.verify_with_custom::<CustomClaims>(token_str)
.await
{
Ok(c) => c,
Err(_) => return Outcome::Error((Status::Unauthorized, ())),
};
Outcome::Success(Authenticated(claims))
}
}
The guard extracts the token from the Authorization header, verifies it using the shared validator with custom claims, and returns the claims. If validation fails, Rocket automatically returns the appropriate HTTP status code.
Protecting the Profile Endpoint
Now we can update the /profile endpoint to use the authentication guard and extract the sub claim:
#[get("/profile")]
fn profile(auth: Authenticated) -> JsonResponse {
let subject = auth
.0
.subject
.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
let response = ProfileResponse {
subject,
message: "Profile endpoint".to_string(),
};
JsonResponse(json::to_string(&response))
}
The Authenticated parameter ensures the request is authenticated before the handler runs. We extract the sub claim and return it in the response.
Complete Application
Here’s the complete src/main.rs:
use jwtiny::{claims, AlgorithmPolicy, ClaimsValidation, RemoteCacheKey, TokenValidator};
use miniserde::json;
use moka::future::Cache;
use reqwest::Client;
use rocket::http::{ContentType, Status};
use rocket::request::{FromRequest, Outcome, Request};
use rocket::response::{Responder, Response};
use rocket::{get, launch, routes};
use std::{io::Cursor, sync::Arc, time::Duration};
#[derive(Clone)]
struct AppState {
validator: TokenValidator,
}
fn create_validator() -> TokenValidator {
let client = Client::new();
let cache = Arc::new(
Cache::<RemoteCacheKey, Vec<u8>>::builder()
.time_to_live(Duration::from_secs(300))
.max_capacity(1000)
.build(),
);
TokenValidator::new()
.algorithms(AlgorithmPolicy::rsa_all())
.issuer(|iss| iss == "http://127.0.0.1:3000")
.validate(ClaimsValidation::default())
.jwks(client)
.cache(cache)
.build()
}
#[claims]
pub struct CustomClaims {
#[serde(rename = "role")]
pub role: Option<String>,
}
pub struct Authenticated(pub CustomClaims);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Authenticated {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let state = match request.rocket().state::<AppState>() {
Some(s) => s,
None => return Outcome::Error((Status::InternalServerError, ())),
};
let auth_header = match request.headers().get_one("authorization") {
Some(h) => h,
None => return Outcome::Error((Status::Unauthorized, ())),
};
let token_str = match auth_header.split_whitespace().nth(1) {
Some(t) => t,
None => return Outcome::Error((Status::BadRequest, ())),
};
let claims = match state
.validator
.verify_with_custom::<CustomClaims>(token_str)
.await
{
Ok(c) => c,
Err(_) => return Outcome::Error((Status::Unauthorized, ())),
};
Outcome::Success(Authenticated(claims))
}
}
#[derive(miniserde::Serialize)]
struct ProfileResponse {
subject: String,
message: String,
}
struct JsonResponse(String);
impl<'r> Responder<'r, 'static> for JsonResponse {
fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'static> {
Response::build()
.status(Status::Ok)
.header(ContentType::JSON)
.sized_body(self.0.len(), Cursor::new(self.0))
.ok()
}
}
#[get("/")]
fn index() -> &'static str {
"Hello, World! API is running."
}
#[get("/profile")]
fn profile(auth: Authenticated) -> JsonResponse {
let subject = auth
.0
.subject
.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
let response = ProfileResponse {
subject,
message: "Profile endpoint".to_string(),
};
JsonResponse(json::to_string(&response))
}
#[launch]
fn rocket() -> _ {
let validator = create_validator();
let state = AppState { validator };
rocket::build()
.manage(state)
.mount("/", routes![index, profile])
}
Setting Up JWKServe
Before we can test the API, we need to start JWKServe. You can install it via Cargo or use Docker:
# Option 1: Install via Cargo
cargo install jwkserve
# Option 2: Use Docker
docker pull sbstjn/jwkserve:latest
Start JWKServe:
# If installed via Cargo
jwkserve serve
# If using Docker
docker run -it -p 3000:3000 sbstjn/jwkserve:latest
You should see output like:
INFO Starting jwkserve
INFO Generating new RSA-2048 key
INFO RSA key size: 2048 bits
INFO Server listening on 0.0.0.0:3000 for issuer http://127.0.0.1:3000
INFO Supported algorithms: [RS256]
Testing with Different Token Payloads
Now let’s test the API with different JWT tokens. JWKServe’s /sign endpoint lets you generate tokens with custom payloads.
Basic Token
First, let’s generate a simple token with a sub claim:
curl -X POST http://localhost:3000/sign \
-H "Content-Type: application/json" \
-d '{
"sub": "user-12345",
"aud": "my-api",
"exp": 1735689600,
"iat": 1704067200
}'
The response includes a JWT token:
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMzQ1IiwiYXVkIjoibXktYXBwIiwiZXhwIjoxNzM1Njg5NjAwLCJpYXQiOjE3MDQwNjcyMDB9.signature_here"
}
Use this token to access the protected endpoint:
curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMzQ1IiwiYXVkIjoibXktYXBwIiwiZXhwIjoxNzM1Njg5NjAwLCJpYXQiOjE3MDQwNjcyMDB9.signature_here" \
http://localhost:8000/profile
You should see:
{
"subject": "user-12345",
"message": "Profile endpoint"
}
Token with Custom Claims
You can add custom claims to test different scenarios. For example, a token with email and role:
curl -X POST http://localhost:3000/sign \
-H "Content-Type: application/json" \
-d '{
"sub": "admin-user",
"aud": "my-api",
"exp": 1735689600,
"iat": 1704067200,
"email": "admin@example.com",
"role": "administrator"
}'
Token with Different Algorithm
JWKServe supports RS256, RS384, and RS512. You can generate tokens with different algorithms:
curl -X POST http://localhost:3000/sign/RS512 \
-H "Content-Type: application/json" \
-d '{
"sub": "user-rs512",
"aud": "my-api",
"exp": 1735689600,
"iat": 1704067200
}'
Since we configured the validator with AlgorithmPolicy::rsa_all(), it accepts all RSA algorithms.
Testing Expired Tokens
To test expiration handling, generate a token with an exp claim in the past:
curl -X POST http://localhost:3000/sign \
-H "Content-Type: application/json" \
-d '{
"sub": "expired-user",
"aud": "my-api",
"exp": 1000000000,
"iat": 1000000000
}'
When you try to use this token, the validator will reject it with a 401 Unauthorized response.
Testing Without Authentication
If you try to access the protected endpoint without a token, you’ll get a 401:
curl http://localhost:8000/profile
# 401 Unauthorized
With an invalid token:
curl -H "Authorization: Bearer invalid-token" http://localhost:8000/profile
# 401 Unauthorized
Complete Testing Workflow
Here’s a complete workflow for testing different scenarios:
# 1. Start JWKServe (in one terminal)
jwkserve serve
# 2. Start the Rocket API (in another terminal)
cargo run
# 3. Generate a token
TOKEN=$(curl -s -X POST http://localhost:3000/sign \
-H "Content-Type: application/json" \
-d '{
"sub": "test-user",
"aud": "my-api",
"exp": 1735689600,
"iat": 1704067200
}' | jq -r '.token')
# 4. Access the protected endpoint
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/profile
# 5. Test with different payloads
TOKEN=$(curl -s -X POST http://localhost:3000/sign \
-H "Content-Type: application/json" \
-d '{
"sub": "different-user",
"aud": "my-api",
"exp": 1735689600,
"iat": 1704067200,
"email": "user@example.com"
}' | jq -r '.token')
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/profile
Custom Claims with Type Safety
The example above already demonstrates custom claims with type safety. The CustomClaims struct includes a role field, and you can extend it with additional custom fields as needed. For example, if you want to access the email claim as well:
#[claims]
pub struct CustomClaims {
#[serde(rename = "email")]
pub email: Option<String>,
#[serde(rename = "role")]
pub role: Option<String>,
}
The #[claims] macro handles the standard claims automatically, so you only need to define your custom fields. The #[serde(rename = "...")] attribute maps JSON field names to your struct fields. Now you can access auth.0.email and auth.0.role in your handlers with full type safety.
Production Considerations
For production use, you’ll want to:
- Configure the issuer to match your actual identity provider
- Set appropriate cache TTL values based on your key rotation schedule
- Add proper error handling and logging
- Consider rate limiting and other security measures
- Use environment variables for configuration
The validator pattern—configure once, reuse everywhere—makes it easy to adjust these settings without changing your request handling code.
That’s It! 🎉
You now have a complete Rust API with JWT authentication using Rocket, JWTiny, and JWKServe. The setup gives you:
- A working Rocket application with protected endpoints
- JWT validation with JWKS support and key caching
- A local identity provider for development and testing
- The ability to test different token payloads and scenarios
The request guard pattern in Rocket makes authentication feel natural, and JWTiny’s reusable validator keeps your code clean and performant. JWKServe eliminates the need for external dependencies during development, making your testing workflow straightforward.
You can find JWTiny on GitHub and install it via cargo add jwtiny. JWKServe is available at github.com/sbstjn/jwkserve and can be installed with cargo install jwkserve or pulled as a Docker image. That’s it! 🎉