#Part 6 - Configuration & Authentication
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:
[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:
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:
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
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:
[dependencies]
# ... existing deps ...
jsonwebtoken = "9"
Create 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:
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-rustlsto 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.
Continue Learning
Part 5 - Load Balancing & Health Checks
In this tutorial, we add load balancing to our API gateway with round-robin distribution and background health checks that automatically remove unhealthy backends from the pool.
Part 4 - Rate Limiting
In this tutorial, we implement a token bucket rate limiter as middleware for our API gateway, protecting backend services from abuse by tracking requests per client IP.
Part 3 - Middleware Pipeline
In this tutorial, we build a composable middleware system using Rust traits. We implement logging, CORS, and custom header injection middleware for our API gateway.
Part 2 - Routing & Path Matching
In the second part of this series, we add a routing layer to our API gateway so it can forward requests to different backend services based on URL path patterns.