Building a Rust API with Rocket and JWT Authentication

December 5th, 2025 1868 Words

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 framework
  • jwtiny: JWT validation library with JWKS support (uses miniserde internally)
  • reqwest: HTTP client for fetching JWKS keys
  • moka: Caching library for key caching
  • tokio: Async runtime
  • miniserde: 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! 🎉


View on GitHub Source code is published using the MIT License.