Part 6 - Configuration & Authentication Image

#Part 6 - Configuration & Authentication

Elliot Forbes Elliot Forbes · Mar 20, 2026 · 9 min read

Welcome to the final part of the series! In the previous tutorial, we added load balancing and health checks. Our gateway is fully functional, but everything is hardcoded in main.rs.

In this tutorial, we’ll make two final improvements: moving all configuration into a YAML file so you can change the gateway’s behavior without recompiling, and adding JWT authentication middleware so the gateway can protect backend services from unauthorized access.

Adding YAML Configuration

Right now, routes and settings are defined in code. That means every change requires a recompile. Let’s fix that by loading configuration from a YAML file.

First, add the dependencies:

Cargo.toml
[dependencies]
# ... existing deps ...
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"

Serde is Rust’s serialization/deserialization framework. If you know Go’s encoding/json with struct tags, serde fills the same role but works with any format (JSON, YAML, TOML, etc.) through different backends. The derive feature lets us use #[derive(Deserialize)] to automatically generate deserialization code from our struct definitions.

Now let’s define the config structure. Create src/config.rs:

src/config.rs
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct GatewayConfig {
    pub server: ServerConfig,
    pub routes: Vec<RouteConfig>,
    #[serde(default)]
    pub rate_limit: RateLimitConfig,
    #[serde(default)]
    pub auth: AuthConfig,
    #[serde(default)]
    pub cors: CorsConfig,
}

#[derive(Debug, Deserialize)]
pub struct ServerConfig {
    pub host: String,
    pub port: u16,
    #[serde(default = "default_health_check_interval")]
    pub health_check_interval_secs: u64,
}

#[derive(Debug, Deserialize)]
pub struct RouteConfig {
    pub path: String,
    pub backends: Vec<String>,
    #[serde(default)]
    pub auth_required: bool,
}

#[derive(Debug, Deserialize)]
pub struct RateLimitConfig {
    #[serde(default = "default_max_tokens")]
    pub max_tokens: u32,
    #[serde(default = "default_refill_rate")]
    pub refill_rate: f64,
    #[serde(default)]
    pub enabled: bool,
}

#[derive(Debug, Deserialize)]
pub struct AuthConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub jwt_secret: String,
}

#[derive(Debug, Deserialize)]
pub struct CorsConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub allowed_origins: Vec<String>,
}

// Default value functions
fn default_health_check_interval() -> u64 { 10 }
fn default_max_tokens() -> u32 { 100 }
fn default_refill_rate() -> f64 { 10.0 }

impl Default for RateLimitConfig {
    fn default() -> Self {
        RateLimitConfig {
            max_tokens: default_max_tokens(),
            refill_rate: default_refill_rate(),
            enabled: false,
        }
    }
}

impl Default for AuthConfig {
    fn default() -> Self {
        AuthConfig {
            enabled: false,
            jwt_secret: String::new(),
        }
    }
}

impl Default for CorsConfig {
    fn default() -> Self {
        CorsConfig {
            enabled: false,
            allowed_origins: Vec::new(),
        }
    }
}

impl GatewayConfig {
    pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
        let contents = std::fs::read_to_string(path)?;
        let config: GatewayConfig = serde_yaml::from_str(&contents)?;
        Ok(config)
    }
}

#[serde(default)] — This tells serde to use the type’s Default implementation if the field is missing from the YAML. This makes our config file flexible — you only specify what you want to change.

#[serde(default = "function_name")] — For fields with custom defaults that differ from the type’s default (e.g., max_tokens defaults to 100, not 0).

Now let’s create an example config file:

ferroway.yaml
server:
  host: "127.0.0.1"
  port: 3000
  health_check_interval_secs: 10

routes:
  - path: "/api/users"
    backends:
      - "http://127.0.0.1:8081"
      - "http://127.0.0.1:8084"
    auth_required: true

  - path: "/api/orders"
    backends:
      - "http://127.0.0.1:8082"
    auth_required: true

  - path: "/"
    backends:
      - "http://127.0.0.1:8080"
    auth_required: false

rate_limit:
  enabled: true
  max_tokens: 50
  refill_rate: 5.0

auth:
  enabled: true
  jwt_secret: "your-secret-key-change-this-in-production"

cors:
  enabled: true
  allowed_origins:
    - "*"

The config is clean and readable. Adding a new route or backend is just a few lines of YAML — no recompilation needed.

Updating main.rs to Use Config

src/main.rs
mod config;
mod health;
mod middleware;
mod proxy;
mod ratelimit;
mod router;

use config::GatewayConfig;
// ... other imports ...

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config_path = std::env::args()
        .nth(1)
        .unwrap_or_else(|| "ferroway.yaml".to_string());

    println!("Loading configuration from {}...", config_path);
    let config = GatewayConfig::from_file(&config_path)?;

    // Build router from config
    let mut router = Router::new();
    println!("Configuring routes...");
    for route_config in &config.routes {
        let backends: Vec<&str> = route_config.backends.iter().map(|s| s.as_str()).collect();
        router.add_route(&route_config.path, backends);
    }

    // Build middleware pipeline from config
    let mut pipeline = MiddlewarePipeline::new();
    println!("Configuring middleware...");

    pipeline.add(Box::new(LoggingMiddleware));

    if config.cors.enabled {
        pipeline.add(Box::new(CorsMiddleware::new(
            config.cors.allowed_origins.clone(),
        )));
    }

    if config.rate_limit.enabled {
        let rate_config = ratelimit::RateLimitConfig::new(
            config.rate_limit.max_tokens,
            config.rate_limit.refill_rate,
        );
        pipeline.add(Box::new(RateLimitMiddleware::new(
            RateLimiter::new(rate_config),
        )));
    }

    if config.auth.enabled {
        pipeline.add(Box::new(JwtAuthMiddleware::new(
            &config.auth.jwt_secret,
            // Collect paths that require auth
            config
                .routes
                .iter()
                .filter(|r| r.auth_required)
                .map(|r| r.path.clone())
                .collect(),
        )));
    }

    // Start health checks
    println!("Starting health checks...");
    let routes_for_health = router.all_routes().to_vec();
    health::start_health_checks(routes_for_health, config.server.health_check_interval_secs);

    let gateway = Arc::new(Gateway { router, pipeline });

    let addr = SocketAddr::from((
        config.server.host.parse::<std::net::Ipv4Addr>()?,
        config.server.port,
    ));
    let listener = TcpListener::bind(addr).await?;
    println!("Ferroway API Gateway listening on http://{}", addr);

    // ... rest of the server loop stays the same ...
    Ok(())
}

Now you can start the gateway with a specific config:

$ cargo run -- ferroway.yaml
Loading configuration from ferroway.yaml...
Configuring routes...
  Route added: /api/users -> [http://127.0.0.1:8081, http://127.0.0.1:8084]
  Route added: /api/orders -> [http://127.0.0.1:8082]
  Route added: / -> [http://127.0.0.1:8080]
Configuring middleware...
  Middleware added: logging
  Middleware added: cors
  Middleware added: rate-limit
  Middleware added: jwt-auth
Starting health checks...
Ferroway API Gateway listening on http://127.0.0.1:3000

JWT Authentication Middleware

Now let’s implement the JWT authentication middleware. Add the jsonwebtoken crate:

Cargo.toml
[dependencies]
# ... existing deps ...
jsonwebtoken = "9"

Create src/middleware/auth.rs:

src/middleware/auth.rs
use super::{Middleware, RequestContext};
use bytes::Bytes;
use http_body_util::{combinators::BoxBody, BodyExt, Full};
use hyper::{Request, Response};
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Claims {
    sub: String,      // Subject (user ID)
    exp: usize,       // Expiry timestamp
    #[serde(default)]
    role: String,     // User role (optional)
}

pub struct JwtAuthMiddleware {
    decoding_key: DecodingKey,
    protected_paths: Vec<String>,
}

impl JwtAuthMiddleware {
    pub fn new(secret: &str, protected_paths: Vec<String>) -> Self {
        JwtAuthMiddleware {
            decoding_key: DecodingKey::from_secret(secret.as_bytes()),
            protected_paths,
        }
    }

    fn is_protected(&self, path: &str) -> bool {
        self.protected_paths
            .iter()
            .any(|prefix| path.starts_with(prefix))
    }

    fn extract_token(req: &Request<BoxBody<Bytes, hyper::Error>>) -> Option<String> {
        req.headers()
            .get("Authorization")
            .and_then(|value| value.to_str().ok())
            .and_then(|value| {
                if value.starts_with("Bearer ") {
                    Some(value[7..].to_string())
                } else {
                    None
                }
            })
    }

    fn make_error_response(
        status: u16,
        message: &str,
    ) -> Response<BoxBody<Bytes, hyper::Error>> {
        let body = Full::new(Bytes::from(format!("{}\n", message)))
            .map_err(|never| match never {})
            .boxed();
        Response::builder()
            .status(status)
            .header("Content-Type", "text/plain")
            .header("WWW-Authenticate", "Bearer")
            .body(body)
            .unwrap()
    }
}

impl Middleware for JwtAuthMiddleware {
    fn on_request(
        &self,
        req: Request<BoxBody<Bytes, hyper::Error>>,
        _ctx: &RequestContext,
    ) -> Result<Request<BoxBody<Bytes, hyper::Error>>, Response<BoxBody<Bytes, hyper::Error>>> {
        let path = req.uri().path().to_string();

        // Skip auth for unprotected paths
        if !self.is_protected(&path) {
            return Ok(req);
        }

        // Extract the token
        let token = match Self::extract_token(&req) {
            Some(t) => t,
            None => {
                return Err(Self::make_error_response(
                    401,
                    "401 Unauthorized\nMissing or malformed Authorization header. Expected: Bearer <token>",
                ));
            }
        };

        // Validate the token
        let validation = Validation::default();
        match decode::<Claims>(&token, &self.decoding_key, &validation) {
            Ok(token_data) => {
                println!(
                    "  [auth] Authenticated user: {} (role: {})",
                    token_data.claims.sub,
                    if token_data.claims.role.is_empty() {
                        "none"
                    } else {
                        &token_data.claims.role
                    }
                );
                // TODO: Could inject user info as headers for the backend
                Ok(req)
            }
            Err(e) => {
                let message = match e.kind() {
                    jsonwebtoken::errors::ErrorKind::ExpiredSignature => {
                        "401 Unauthorized\nToken has expired."
                    }
                    jsonwebtoken::errors::ErrorKind::InvalidToken => {
                        "401 Unauthorized\nInvalid token."
                    }
                    _ => "401 Unauthorized\nToken validation failed.",
                };
                Err(Self::make_error_response(401, message))
            }
        }
    }

    fn name(&self) -> &str {
        "jwt-auth"
    }
}

Let’s highlight a few things:

Option chaining with and_then — The extract_token method chains several Option operations together. get() returns Option, to_str() returns Result (converted to Option with .ok()), and the final closure checks the “Bearer " prefix. If any step produces None, the whole chain short-circuits. This is similar to optional chaining (?.) in TypeScript, but enforced by the compiler.

Pattern matching on error kinds — The match e.kind() gives different error messages for different failure modes. The user gets a clear message about whether their token is expired, malformed, or otherwise invalid.

WWW-Authenticate header — This is the standard HTTP header that tells clients they need to provide authentication. Good API gateway behavior means following HTTP standards.

Register the module in src/middleware/mod.rs:

pub mod auth;
pub mod cors;
pub mod headers;
pub mod logging;
pub mod ratelimit;

Testing Authentication

To test JWT auth, we need to generate a token. You can use any JWT tool, or add a quick token generator as an example:

examples/gen_token.rs
use jsonwebtoken::{encode, EncodingKey, Header};
use serde::Serialize;

#[derive(Serialize)]
struct Claims {
    sub: String,
    role: String,
    exp: usize,
}

fn main() {
    let secret = std::env::args()
        .nth(1)
        .unwrap_or_else(|| "your-secret-key-change-this-in-production".to_string());

    let claims = Claims {
        sub: "user-123".to_string(),
        role: "admin".to_string(),
        // Expires in 1 hour
        exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() as usize,
    };

    let token = encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
    .unwrap();

    println!("{}", token);
}

Generate a token and test:

# Generate a token
$ TOKEN=$(cargo run --example gen_token)

# Request without auth to a protected route
$ curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/users
401

# Request with valid auth
$ curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/users
Response from: user-service-1

# Request to an unprotected route (no auth needed)
$ curl http://localhost:3000/health
Response from: default-service

The gateway enforces authentication only on the paths marked auth_required: true in the YAML config. Public endpoints like / pass through without any token.

The Complete Architecture

Let’s step back and look at what we’ve built across this series:

                    ┌─────────────────────────────────────┐
                    │         Ferroway API Gateway         │
                    │                                      │
  Client Request ──>│  Logging ──> CORS ──> Rate Limit    │
                    │     │                    │           │
                    │     v                    v           │
                    │  JWT Auth ──> Router ──> Load        │
                    │                │        Balancer     │
                    │                v            │        │
                    │         ┌──────────┐        │        │
                    │         │  Route   │        v        │
                    │         │ Matching │   ┌─────────┐   │
                    │         └──────────┘   │ Backend │──>│──> Backend 1
                    │                        │  Pool   │──>│──> Backend 2
                    │  Health Checks ──────> │         │──>│──> Backend 3
                    │   (background)         └─────────┘   │
                    └─────────────────────────────────────┘

All configured via a single YAML file. That’s a real API gateway.

Conclusion

Over these six parts, we’ve built a fully functional API gateway in Rust covering reverse proxying, path-based routing, a composable middleware system, rate limiting with token buckets, round-robin load balancing with health checks, YAML-driven configuration, and JWT authentication.

Along the way, we’ve covered a huge amount of Rust: ownership and borrowing, async/await with Tokio, traits for abstraction, Arc and Mutex and RwLock for concurrency, pattern matching, error handling, and serde for serialization.

Where to Go from Here

If you want to keep building on Ferroway, here are some ideas:

  • HTTPS/TLS termination — Use tokio-rustls to accept HTTPS connections and forward as HTTP to backends
  • WebSocket proxying — Support upgrading HTTP connections to WebSocket
  • Request/response body transformation — Modify JSON payloads as they pass through
  • Metrics and observability — Export Prometheus metrics for request counts, latencies, and error rates
  • Circuit breaker — If a backend fails repeatedly, stop sending traffic for a cooldown period
  • Hot config reload — Watch the YAML file for changes and apply them without restarting

Hopefully you’ve enjoyed building this project and have a solid understanding of both API gateway architecture and practical Rust! If you have any questions or feedback, I’d love to hear from you in the comments below.