← Back to list

Tokio, Tower, Hyper and Rustls: Building High-Performance and Secure Servers in Rust — Part 6

In the earlier parts of this series, we explored the fundamentals of building HTTP(S) servers with (m)TLS using Rust. In this article…

Alfred Weirich · 2025-07-07 14:19 · 15 claps · 7.8 min read
#rust #tower #hyper #rustl
Open on Medium ↗

Tokio, Tower, Hyper and Rustls: Building High-Performance and Secure Servers in Rust — Part 6

In the earlier parts of this series, we explored the fundamentals of building HTTP(S) servers with (m)TLS using Rust. In this article, we’ll extend our architecture by introducing additional middleware layers that add functionality to the request/response pipeline. Specifically, we’ll look at three layers:

  • Delay Layer — A testing utility that introduces an artificial delay before a request is processed. It also serves as a clear example of implementing asynchronous logic inside the poll_ready() method.
  • JWT Layer — A security layer that enables client authentication via JSON Web Tokens (JWT), offering an alternative to mTLS.
  • Inspection Layer — A filter that inspects request paths using configurable regular expressions and only allows approved patterns.

Delay Layer

The Delay Layer introduces a configurable delay before a request is passed down the service stack. It’s especially useful for simulating slow responses or testing timeouts.

This delay is implemented inside the poll_ready() method by using a Tokio Sleep future. The method returns Poll::Pending until the sleep has elapsed, at which point the inner service becomes available. The call() method, on the other hand, remains unchanged and immediately forwards the request to the next layer.

Configuration

The delay can be enabled and configured in Config.toml as follows:

...
# activate the delay-layer
# the layers configured/enabled for this server
[Server.Layers]
enabled = [.., "Delay", ...]
...
# Configure the Delay layer
[Server.Layers.Delay]
delay_micros = 10
...

This configuration injects a 10-microsecond delay before the server processes each request.

Implementation

Here’s the implementation of the DelayLayer and its corresponding service:

use std::{
    future::Future,
    pin::Pin,
    sync::Arc,
    task::{Context, Poll},
    time::Duration,
};
use tokio::time::{self, Sleep};
use tower::{Layer, Service};

/// A Tower layer that adds an artificial delay to poll_ready and tags logs with a server name.
#[derive(Clone)]
pub struct DelayLayer {
    delay: Duration,
    server_name: Arc<String>,
}
impl DelayLayer {
    pub fn new(delay: Duration, server_name: impl Into<String>) -> Self {
        Self {
            delay,
            server_name: Arc::new(server_name.into()),
        }
    }
}
impl<S> Layer<S> for DelayLayer {
    type Service = DelayService<S>;
    fn layer(&self, inner: S) -> Self::Service {
        DelayService {
            inner,
            delay: self.delay,
            sleep: None,
            server_name: Arc::clone(&self.server_name),
        }
    }
}
pub struct DelayService<S> {
    inner: S,
    delay: Duration,
    sleep: Option<Pin<Box<Sleep>>>,
    server_name: Arc<String>,
}
impl<S: Clone> Clone for DelayService<S> {
    fn clone(&self) -> Self {
        Self {
            inner: self.inner.clone(),
            delay: self.delay,
            sleep: None, // Don't clone sleep state!
            server_name: Arc::clone(&self.server_name),
        }
    }
}
impl<S, Request> Service<Request> for DelayService<S>
where
    S: Service<Request>,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = S::Future;
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        // If we haven't started sleeping yet, create a new Sleep.
        if self.sleep.is_none() {
            tracing::info!(
                "{}: Injecting a delay of {:?} before readiness",
                self.server_name,
                self.delay
            );
            self.sleep = Some(Box::pin(time::sleep(self.delay)));
        }
        // Now, poll the Sleep future.
        let sleep = self.sleep.as_mut().unwrap();
        if Pin::new(sleep).poll(cx).is_pending() {
            return Poll::Pending;
        }
        // Sleep is done, clear it for next time.
        self.sleep = None;
        // Now delegate to the inner service.
        self.inner.poll_ready(cx)
    }
    fn call(&mut self, req: Request) -> Self::Future {
        tracing::info!(
            "{}: Passing request through DelayLayer",
            self.server_name
        );
        self.inner.call(req)
    }
}

JWT Layer

As an alternative to mutual TLS (mTLS), JSON Web Tokens (JWT) can be used for client authentication and authorization. In this setup, JWTs are signed with a private key, and the server holds the corresponding public key(s) to verify incoming tokens. Sample code for generating JWTs is available in the associated GitHub repository.

Configuration

To enable the JWT layer, you must add it to the list of enabled layers in Config.toml. The only required configuration is a list of paths to public keys, which will be used to verify incoming tokens:

...
# the layers configured/enabled for this server
[Server.Layers]
#enabled = [..., "JWT", ...]
...
#  Path to keys in case of authentication = "JWT"
#  this can be a list of jwt_public_keys
[Server.Layers.JWT]
jwt_public_keys=["./jwt/public_key.pem"]

This configuration supports multiple public keys, allowing for key rotation or support for multiple issuers. The layer only allows requests that include an Authorization header in the format:

Authorization: Bearer <token>

Requests without a valid token will be rejected with a 401 Unauthorized response.

Implementation and Code Overview

The JWT layer is implemented as a Tower Layer, wrapping a service to enforce authentication before forwarding the request.

Key Components:

  • **JwtAuthLayer**: Initializes the layer with one or more public keys used to validate incoming JWTs. These keys are loaded from the provided file paths using a helper function load_decoding_keys().
  • **JwtAuthService**: Intercepts each incoming request, extracts the bearer token from the Authorization header, and validates it using the verify_jwt() function. If the token is valid, the request continues to the inner service. Otherwise, the layer returns an unauthorized response.
use std::{
    future::Future,
    pin::Pin,
    sync::Arc,
    task::{Context, Poll},
};
use bytes::Bytes;
use http_body_util::Full;
#[cfg(feature = "boxed_body")]
use http_body_util::BodyExt;
use hyper::{Request, Response, StatusCode};
use tower::{Layer, Service};
use tracing::error;
use server::ServiceRespBody;
#[cfg(feature = "boxed_body")]
use server::SrvError;
use jsonwebtoken::DecodingKey; // <-- ADDED
use crate::utils::{Claims, load_decoding_keys, verify_jwt};
#[derive(Clone)]
pub struct JwtAuthLayer {
    decoding_keys: Arc<Vec<DecodingKey>>,
    server_name: Arc<String>,
}
impl JwtAuthLayer {
    #[allow(dead_code)]
    pub fn new(key_files: Vec<String>, server_name: impl Into<String>) -> Self {
        let decoding_keys = load_decoding_keys(&key_files);
        Self {
            decoding_keys: Arc::new(decoding_keys),
            server_name: Arc::new(server_name.into()),
        }
    }
}
impl<S> Layer<S> for JwtAuthLayer {
    type Service = JwtAuthService<S>;
    fn layer(&self, inner: S) -> Self::Service {
        JwtAuthService {
            inner,
            decoding_keys: self.decoding_keys.clone(),
            server_name: Arc::clone(&self.server_name),
        }
    }
}
#[derive(Clone)]
pub struct JwtAuthService<S> {
    inner: S,
    decoding_keys: Arc<Vec<DecodingKey>>,
    server_name: Arc<String>,
}
impl<S, ReqBody> Service<Request<ReqBody>> for JwtAuthService<S>
where
    S: Service<Request<ReqBody>, Response = Response<ServiceRespBody>> + Clone + Send + 'static,
    S::Future: Send + 'static,
    ReqBody: Send + 'static,
{
    type Response = Response<ServiceRespBody>;
    type Error = S::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }
    fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
        let mut inner = self.inner.clone();
        let decoding_keys = self.decoding_keys.clone();
        let server_name = Arc::clone(&self.server_name);
        Box::pin(async move {
            // extract the token string from the request's Authorization header
            let token = req
                .headers()
                .get("Authorization")
                .and_then(|v| v.to_str().ok())
                .and_then(|s| s.strip_prefix("Bearer "))
                .map(str::trim);
            match token {
                Some(token) => match verify_jwt(token, &decoding_keys) {
                    Ok(_claims) => {
                        req.extensions_mut().insert::<Claims>(_claims);
                        inner.call(req).await
                    }
                    Err(e) => {
                        error!("{}: Invalid JWT: {:?}", server_name, e);
                        unauthorized_response()
                    }
                },
                None => {
                    error!("{}: Missing or invalid Authorization header", server_name);
                    unauthorized_response()
                }
            }
        })
    }
}
fn unauthorized_response<T>() -> Result<Response<ServiceRespBody>, T> {
    #[cfg(feature = "boxed_body")]
    let body: ServiceRespBody = Full::new(Bytes::from("Unauthorized"))
        .map_err(SrvError::from)
        .boxed();
    #[cfg(not(feature = "boxed_body"))]
    let body: ServiceRespBody = Full::new(Bytes::from("Unauthorized"));
    let mut resp: Response<ServiceRespBody> = Response::new(body);
    *resp.status_mut() = StatusCode::UNAUTHORIZED;
    Ok(resp)
}

Inspection Layer

The Inspection Layer adds an additional security layer by restricting access to certain paths based on regular expression (regex) patterns. While the underlying service may expose multiple endpoints, this layer ensures only explicitly approved URL patterns are accessible.

This can be particularly useful when:

  • You want to restrict production endpoints while leaving others available in development.
  • You need fine-grained, method-based access control (e.g., only allow certain GET requests with specific query parameters).

Configuration

The allowed paths are defined in the Config.toml file. For each HTTP method (GET, POST, etc.), you can specify a map of endpoints and their allowed regex patterns:

...
# Enable the Inspection layer
[Server.Layers]
enabled = ["Inspection"]
...
# Define allowed GET paths with regex validation
[Server.AllowedPathes.GET]
"/" = ["^/?$", "^/\\?name=.*$"]
"/help" = ["^/help\\??(topic=.*)?$"]
"/name" = ["^/name\\??id=\\d+$"]

With the above setup:

  • Only GET /, GET /?name=..., GET /help, GET /help?topic=..., and GET /name?id=123 are allowed.
  • Any other paths or query patterns will be blocked with a 403 Forbidden.

You can also define Rules for POST, PUT, etc.

How the Code Works

InspectionLayer and InspectionService: These are standard Tower middleware structures. The InspectionLayer wraps an inner service with logic that checks each request against a set of allowed patterns.

use bytes::Bytes;
#[cfg(feature = "boxed_body")]
use http_body_util::BodyExt;
use http_body_util::Full;
use hyper::{Request, Response, StatusCode};
use std::{
    future::Future,
    pin::Pin,
    sync::Arc,
    task::{Context, Poll},
};
use tower::{Layer, Service};
use crate::configuration::CompiledAllowedPathes; // Adjust to your module path
#[cfg(feature = "boxed_body")]
use server::SrvError;
#[derive(Clone)]
pub struct InspectionLayer {
    rules: Arc<CompiledAllowedPathes>,
    server_name: Arc<String>,
}
impl InspectionLayer {
    /// Create a new InspectionLayer with rules and a server name.
    pub fn new(rules: CompiledAllowedPathes, server_name: impl Into<String>) -> Self {
        Self {
            rules: Arc::new(rules),
            server_name: Arc::new(server_name.into()),
        }
    }
}
impl<S> Layer<S> for InspectionLayer {
    type Service = InspectionService<S>;
    fn layer(&self, inner: S) -> Self::Service {
        InspectionService {
            inner,
            allowed_pathes: self.rules.clone(),
            server_name: Arc::clone(&self.server_name),
        }
    }
}
#[derive(Clone)]
pub struct InspectionService<S> {
    inner: S,
    allowed_pathes: Arc<CompiledAllowedPathes>,
    server_name: Arc<String>,
}
use server::ServiceRespBody;
impl<S, ReqBody> Service<Request<ReqBody>> for InspectionService<S>
where
    S: Service<Request<ReqBody>, Response = Response<ServiceRespBody>> + Clone + Send + 'static,
    S::Future: Send + 'static,
    ReqBody: Send + 'static,
{
    type Response = Response<ServiceRespBody>;
    type Error = S::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }
    fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
        // Extracts the method, path, and query string.
        // Check if the request is allowed via the is_allowed function.
        let method = req.method().as_str().to_uppercase();
        let uri = req.uri().clone();
        let path = uri.path().to_string();
        let query = uri.query().unwrap_or("").to_string();
        let allow = self.allowed_pathes.is_allowed(&method, &path, &query);
        let server_name = Arc::clone(&self.server_name);
        if allow {
            //If the request matches one of the allowed regex patterns
            let mut inner = self.inner.clone();
            Box::pin(async move { inner.call(req).await })
        } else {
            // if not allowed:
            // Log the blocked attempt.
            // Return a 403 Forbidden response with a simple body.
            tracing::warn!(
                "{}: Blocked request: {} {}?{}",
                server_name,
                method,
                path,
                query
            );
            #[cfg(feature = "boxed_body")]
            let body: ServiceRespBody =
                Full::new(Bytes::from("Request does not match allowed patterns"))
                    .map_err(SrvError::from)
                    .boxed();
            #[cfg(not(feature = "boxed_body"))]
            let body: ServiceRespBody =
                Full::new(Bytes::from("Request does not match allowed patterns"));
            let response = Response::builder()
                .status(StatusCode::FORBIDDEN)
                .body(body)
                .expect("Failed to build response");
            Box::pin(async move { Ok(response) })
        }
    }
}

Path Matching Logic

Function is_allowed:

  • Builds the full path including the query string.
  • Looks up regex patterns for the given method and path.
  • Returns true if any pattern matches the full path.
  • Returns false if no patterns match or if the method is unsupported.
pub fn is_allowed(&self, method: &str, path: &str, query: &str) -> bool {
        let full_path = if query.is_empty() {
            path.to_string()
        } else {
            format!("{}?{}", path, query)
        };
        let map = match method {
            "GET" => &self.get,
            "POST" => &self.post,
            "PUT" => &self.put,
            "DELETE" => &self.delete,
            _ => return false,
        };
        map.get(path)
            .map(|regexes| regexes.iter().any(|r| r.is_match(&full_path)))
            .unwrap_or(false)
    }

Regex Compilation on Startup

To avoid recompiling regexes on each request, the system compiles all configured patterns at startup:

pub fn from_raw(routes: &Option<AllowedPathes>) -> Result<Self, Error> {
        fn compile(
            map: &Option<HashMap<String, Vec<String>>>,
        ) -> Result<HashMap<String, Vec<Regex>>, Error> {
            let mut compiled = HashMap::new();
            if let Some(map) = map {
                for (k, patterns) in map {
                    let regexes = patterns
                        .iter()
                        .map(|p| {
                            Regex::new(p).map_err(|e| Error::msg(format!("Invalid regex: {}", e)))
                        })
                        .collect::<Result<Vec<_>, _>>()?;
                    compiled.insert(k.clone(), regexes);
                }
            }
            Ok(compiled)
        }
        Ok(Self {
            get: compile(&routes.as_ref().and_then(|r| r.get.clone()))?,
            post: compile(&routes.as_ref().and_then(|r| r.post.clone()))?,
            put: compile(&routes.as_ref().and_then(|r| r.put.clone()))?,
            delete: compile(&routes.as_ref().and_then(|r| r.delete.clone()))?,
        })
    }

This ensures:

  • Patterns are validated early (bad regexes will crash the server on startup).
  • Runtime performance is optimal (no re-parsing per request).

Summary

The Inspection Layer is a powerful security tool that:

  • Provides fine-grained access control per HTTP method.
  • Uses fast precompiled regexes.
  • Acts as a gatekeeper before requests reach inner logic.

It’s especially useful for:

  • Locking down public APIs
  • Validating query parameters
  • Controlling unexpected or undocumented routes

Conclusion

In this part of the series, we expanded our Rust-based server architecture by introducing three powerful and reusable Tower layers:

  • The Delay Layer, useful for testing response timing and simulating slow services.
  • The JWT Layer, which provides secure and configurable client authentication using public-key verified tokens.
  • The Inspection Layer, which acts as a customizable access control mechanism using regex-based path validation.

These layers showcase how Tower’s middleware model allows you to build composable, testable, and production-ready server features with minimal overhead. They also demonstrate how security and performance considerations can be embedded directly into the service stack, rather than added as afterthoughts.

In the final part of the series, we’ll complete the picture by:

  • Implementing a benchmarking client that supports both JWT and (m)TLS authentication for performance testing.
  • Introducing a routing layer that transforms our server into a basic reverse proxy — enabling dynamic request forwarding based on request path.

With these additions, the server evolves from a secure microservice into a flexible and extensible platform, suitable for more complex deployment scenarios.


메타데이터
post_id
ebb5cdf120d8
slug
tokio-tower-hyper-and-rustls-building-high-performance-and-secure-servers-in-rust-part-6-ebb5cdf120d8
url
https://medium.com/@alfred.weirich/tokio-tower-hyper-and-rustls-building-high-performance-and-secure-servers-in-rust-part-6-ebb5cdf120d8
canonical_url
https://medium.com/@alfred.weirich/tokio-tower-hyper-and-rustls-building-high-performance-and-secure-servers-in-rust-part-6-ebb5cdf120d8
author_url
https://medium.com/@alfred.weirich
status
ok
fetched_at
2026-07-19 02:56:21