All posts

Rust

Sharing Authentication Between Rails and a Rust Axum Microservice

You’ve got a Rust microservice fronting a Rails-owned database (Part 2) and a streaming endpoint pushing live events to connected clients (Part 3). The next question is always the same: how does the Rust service know who the user is?

Until you answer that, every endpoint is either anonymous or naively trusts a header, and neither is acceptable for anything beyond a public read. This post walks through the realistic options for sharing authentication between Rails and Rust, with full code for each. Same three-pass structure: beginner (the conceptual problem, a bearer-token JWT flow), intermediate (verifying Rails-issued JWTs and decrypting Rails session cookies), advanced (CSRF, the gateway pattern, mTLS, and per-user rate limiting).

A note up front: don’t roll your own crypto. Every code sample in this post uses a well-reviewed crate (jsonwebtoken, aes-gcm, cookie). If you find yourself reaching for aes and writing your own block-cipher mode, stop. There’s a crate for what you’re trying to do.


The shape of the problem

The classic monolith owns identity: login forms, password storage, session creation, email verification, password reset, OAuth flows, MFA. All of that lives in Rails (or Django, or Laravel, or Node). You almost never want to duplicate it into Rust: that’s an enormous surface area and a security liability.

So the Rust service needs to consume an identity assertion that the monolith already produced. Four options:

Approach What flows over the wire When to pick it
JWT A signed token, usually in Authorization: Bearer ... The default. Works for SPAs, mobile, server-to-server.
Rails encrypted cookie The Rails _app_session cookie, untouched Browser is already logged in to Rails and you want zero client changes.
Shared session store A session ID; both services read Redis You already use a server-side session store.
Gateway / trusted header An upstream proxy authenticates; Rust trusts X-User-ID You have a real edge gateway (Envoy, Kong, Cloudflare Access).

Most teams I’ve seen end up using JWT for anything new and falling back to Rails cookie decryption only for endpoints that have to coexist with the existing Rails session (e.g., a chunk of a logged-in dashboard being served from Rust).


Beginner: A bearer-token JWT flow

Let’s wire up the simplest end-to-end auth path. The Rails app issues a JWT after login; the Rust service verifies it on every request.

Step 1: Issue a JWT from Rails

# Gemfile
gem "jwt"

# app/services/issue_jwt.rb
class IssueJwt
  ALGO = "HS256"
  ISSUER = "rails-monolith"
  AUDIENCE = "rust-services"

  def self.call(user)
    now = Time.now.to_i
    payload = {
      sub: user.id.to_s,
      email: user.email,
      iss: ISSUER,
      aud: AUDIENCE,
      iat: now,
      exp: now + 15 * 60,           # 15 minutes
      jti: SecureRandom.uuid,       # unique token id for revocation
    }
    JWT.encode(payload, ENV.fetch("JWT_SECRET"), ALGO)
  end
end

# Sessions controller, after a successful login:
render json: { token: IssueJwt.call(@user) }

A few things to internalize about JWTs before we touch Rust:

  • The token is signed, not encrypted. The payload (sub, email, etc.) is base64-encoded but readable. Don’t put secrets in a JWT.
  • HS256 uses a shared secret. Rails and Rust both need JWT_SECRET. For multiple services consuming, prefer RS256 (asymmetric): Rails signs with a private key, every other service verifies with the public key.
  • exp is mandatory in practice. 15 minutes is a reasonable default for access tokens. Long-lived sessions get a separate refresh-token endpoint.
  • iss and aud matter. Verify them on the Rust side. A token issued for a different service should not be accepted just because the signature checks out.

Step 2: Verify the JWT in Rust

Add jsonwebtoken:

[dependencies]
# ...existing...
jsonwebtoken = "9"

The verification logic:

// src/auth/jwt.rs
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,    // user id (we serialize uuids as strings)
    pub email: String,
    pub iss: String,
    pub aud: String,
    pub iat: i64,
    pub exp: i64,
    pub jti: String,
}

#[derive(Clone)]
pub struct JwtVerifier {
    key: DecodingKey,
    validation: Validation,
}

impl JwtVerifier {
    pub fn new(secret: &[u8]) -> Self {
        // Build a `Validation` config explicitly. By default `Validation`
        // checks `exp` but not `iss` or `aud` — we want both.
        let mut validation = Validation::new(Algorithm::HS256);
        validation.set_issuer(&["rails-monolith"]);
        validation.set_audience(&["rust-services"]);
        validation.leeway = 30;        // allow 30s of clock skew between services

        Self {
            key: DecodingKey::from_secret(secret),
            validation,
        }
    }

    pub fn verify(&self, token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
        // `decode` does signature verification, exp/iss/aud checks, and
        // payload deserialization in one call. If anything fails, it returns Err.
        let data = decode::<Claims>(token, &self.key, &self.validation)?;
        Ok(data.claims)
    }
}

A few points to absorb:

  • Validation::new(Algorithm::HS256) pins the algorithm. This matters because of the infamous “alg: none” attack: an attacker sends a token with alg: none and no signature, and a naive verifier accepts it. Pinning the expected algorithm makes that impossible.
  • leeway = 30 smooths over the inevitable seconds of clock drift between hosts. Without it, a token issued at second N may briefly look “not yet valid” to a Rust pod whose clock is ahead by 1s.
  • set_issuer / set_audience are not defaults. Forgetting them is the second most common JWT mistake (after not pinning the algorithm).

Step 3: An Axum extractor for AuthUser

This is where Axum gets fun. We’re going to implement a custom extractor so handlers can just write:

async fn me(user: AuthUser) -> Json<Whoami> { ... }

and the framework handles “find the Authorization header, verify the token, populate the user, return 401 if anything failed.” No middleware boilerplate per route.

// src/auth/extractor.rs
use axum::{
    async_trait,
    extract::{FromRef, FromRequestParts},
    http::{request::Parts, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use super::jwt::{Claims, JwtVerifier};

#[derive(Debug, Clone)]
pub struct AuthUser {
    pub id: String,
    pub email: String,
    pub claims: Claims,
}

// The error type returned when extraction fails. By implementing IntoResponse
// we control exactly what 401 looks like over the wire.
pub struct AuthError(pub &'static str);

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let body = Json(json!({ "error": self.0 }));
        (StatusCode::UNAUTHORIZED, body).into_response()
    }
}

// `FromRequestParts` is the trait every "lightweight" extractor implements.
// It runs *before* the body is consumed, which means many of these can
// stack on the same handler without conflict.
//
// The `S` generic is the app state type. `FromRef<S>` on `JwtVerifier`
// means: "I can pull a `JwtVerifier` out of the state."
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
    JwtVerifier: FromRef<S>,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        // 1. Pull the verifier out of the app state.
        let verifier = JwtVerifier::from_ref(state);

        // 2. Find the Authorization header.
        let header = parts
            .headers
            .get(axum::http::header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .ok_or(AuthError("missing authorization header"))?;

        // 3. Strip the `Bearer ` prefix. Reject if the format is wrong.
        let token = header
            .strip_prefix("Bearer ")
            .ok_or(AuthError("expected Bearer token"))?;

        // 4. Verify. If anything's wrong — signature, expiry, issuer,
        //    audience — we get a clean 401.
        let claims = verifier
            .verify(token)
            .map_err(|_| AuthError("invalid or expired token"))?;

        Ok(AuthUser {
            id: claims.sub.clone(),
            email: claims.email.clone(),
            claims,
        })
    }
}

Now register the verifier in your state and use the extractor:

#[derive(Clone)]
struct AppState {
    db: PgPool,
    jwt: JwtVerifier,
}

// `FromRef` lets the AuthUser extractor pull JwtVerifier out of AppState.
impl FromRef<AppState> for JwtVerifier {
    fn from_ref(state: &AppState) -> Self {
        state.jwt.clone()
    }
}

async fn me(user: AuthUser) -> Json<Whoami> {
    Json(Whoami { id: user.id, email: user.email })
}

async fn list_orders(
    user: AuthUser,                          // 401 if not logged in
    State(state): State<AppState>,
) -> Result<Json<Vec<Order>>, AppError> {
    let orders = sqlx::query_as::<_, Order>(
        "SELECT id, customer_id, total_cents, status, created_at, updated_at
         FROM orders
         WHERE customer_id = $1 AND deleted_at IS NULL
         ORDER BY created_at DESC",
    )
    .bind(&user.id)
    .fetch_all(&state.db)
    .await?;

    Ok(Json(orders))
}

That’s the whole flow. Pull a header, verify a token, expose AuthUser to handlers. The handler never sees the JWT; it just gets a typed user object.

Test it:

TOKEN=$(curl -s -XPOST http://rails:3000/sessions ... | jq -r .token)

curl http://rust:3000/me -H "Authorization: Bearer $TOKEN"
# {"id":"123","email":"[email protected]"}

curl http://rust:3000/me
# {"error":"missing authorization header"}    (HTTP 401)

Beginner best practices

  • Always pin the algorithm. Validation::new(Algorithm::HS256). Never let the verifier accept whatever’s in the token header.
  • Always verify iss and aud. It’s two lines of config and it stops a class of cross-service token-replay attacks dead.
  • Keep exp short. 15 minutes is a good default for access tokens. Force the client to refresh.
  • Don’t put PII or secrets in the payload. Anyone with the token can base64-decode it.
  • Log token failures. tracing::warn!(error = ?e, "auth failed"). You want to see when something is wrong long before a user reports it.

Sometimes the constraint is “the user is already logged into Rails, and I have a slice of the Rails-rendered page that I want to serve from Rust.” The user’s browser has a Rails session cookie (_app_session). Asking them to log in again, or issuing a JWT just to immediately drop into Rust, is awkward.

The honest answer: Rust can decrypt the Rails session cookie, but it’s the most fragile path of any approach in this post. Rails changes the cookie format between major versions (Rails 5 → 6 changed the cookie serializer; Rails 7 stabilized AES-GCM). You will need to revisit this code on every Rails upgrade.

If you can avoid it, do. If you can’t, here’s how.

How Rails encrypts session cookies (Rails 7+)

  1. The cookie value is URL-safe base64 of a JSON envelope: {"_rails":{"message":"<inner>","exp":"...","pur":"cookie._app_session"}}.
  2. The inner message is base64 of iv || ciphertext || auth_tag (12 + N + 16 bytes), AES-256-GCM encrypted.
  3. The key is derived from secret_key_base via HKDF (the key_generator), with the salt being "authenticated encrypted cookie" by default.
  4. The plaintext is the marshalled session payload (Marshal by default, JSON if you set cookies_serializer = :json, and use JSON if you can; parsing Ruby Marshal in Rust is painful).

The crates we need:

[dependencies]
aes-gcm = "0.10"
hkdf = "0.12"
sha2 = "0.10"
base64 = "0.22"
cookie = "0.18"

A reusable decryptor:

// src/auth/rails_cookie.rs
use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hkdf::Hkdf;
use serde::Deserialize;
use sha2::Sha256;

const SALT: &[u8] = b"authenticated encrypted cookie";
const KEY_SIZE: usize = 32;     // AES-256 key
const IV_SIZE: usize = 12;      // GCM nonce
const TAG_SIZE: usize = 16;     // GCM auth tag

#[derive(Debug, thiserror::Error)]
pub enum CookieError {
    #[error("base64 decode failed")]
    Base64(#[from] base64::DecodeError),
    #[error("envelope parse failed")]
    Envelope(#[from] serde_json::Error),
    #[error("ciphertext too short")]
    TooShort,
    #[error("decryption failed (likely wrong key or tampered cookie)")]
    Decrypt,
    #[error("utf8 plaintext expected")]
    Utf8(#[from] std::string::FromUtf8Error),
}

#[derive(Clone)]
pub struct RailsCookieDecryptor {
    cipher: Aes256Gcm,
}

impl RailsCookieDecryptor {
    /// `secret_key_base` is the Rails secret. `key_generator` would normally
    /// take ~1000 iterations; we replicate that with HKDF-Extract+Expand.
    pub fn new(secret_key_base: &[u8]) -> Self {
        let hk = Hkdf::<Sha256>::new(Some(SALT), secret_key_base);
        let mut key = [0u8; KEY_SIZE];
        hk.expand(b"", &mut key).expect("hkdf expand");
        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
        Self { cipher }
    }

    pub fn decrypt(&self, cookie_value: &str) -> Result<String, CookieError> {
        // 1. URL-decode + base64-decode the outer envelope.
        let decoded = URL_SAFE_NO_PAD.decode(cookie_value)?;

        // 2. Parse the JSON envelope.
        #[derive(Deserialize)]
        struct Envelope<'a> {
            #[serde(rename = "_rails")]
            rails: RailsField<'a>,
        }
        #[derive(Deserialize)]
        struct RailsField<'a> {
            message: &'a str,
        }
        let envelope: Envelope = serde_json::from_slice(&decoded)?;

        // 3. Decode the inner message: iv || ciphertext || tag
        let inner = URL_SAFE_NO_PAD.decode(envelope.rails.message)?;
        if inner.len() < IV_SIZE + TAG_SIZE {
            return Err(CookieError::TooShort);
        }
        let (iv, rest) = inner.split_at(IV_SIZE);
        let (ciphertext, tag) = rest.split_at(rest.len() - TAG_SIZE);

        // 4. aes-gcm expects ciphertext || tag concatenated. Recombine.
        let mut combined = Vec::with_capacity(ciphertext.len() + TAG_SIZE);
        combined.extend_from_slice(ciphertext);
        combined.extend_from_slice(tag);

        // 5. Decrypt.
        let plaintext = self
            .cipher
            .decrypt(Nonce::from_slice(iv), combined.as_ref())
            .map_err(|_| CookieError::Decrypt)?;

        Ok(String::from_utf8(plaintext)?)
    }
}

A few things to call out before you copy-paste:

  • This implementation assumes config.action_dispatch.cookies_serializer = :json in Rails. If your Rails app still uses the Marshal serializer (the historical default before Rails 7), the plaintext you get back is a Ruby Marshal blob and you’ll need a separate Marshal parser. Switching to JSON in Rails is a one-line config change and dramatically simplifies cross-language access.
  • The salt may differ in your app. Check Rails.application.config.action_dispatch.encrypted_cookie_salt; older apps may have changed it. The Rust code must use the same value.
  • key_generator in Rails uses PBKDF2 with 1000 iterations by default for derivation, but for the cookie key path most modern Rails versions use HKDF directly. If your derived key produces decryption failures, you may be on the PBKDF2 path: swap hkdf::Hkdf for pbkdf2::pbkdf2_hmac::<Sha256> with Rails.application.key_generator.iterations rounds.
  • This will break on Rails upgrades that touch session encryption. Pin a CI test that decrypts a known-good cookie against the current Rails version, so an upgrade fails CI loudly instead of breaking auth in production.

An extractor for Rails session users

// src/auth/rails_extractor.rs
use axum::{
    async_trait,
    extract::{FromRef, FromRequestParts},
    http::{request::Parts, header::COOKIE, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use cookie::Cookie;
use serde::Deserialize;
use serde_json::json;
use super::rails_cookie::RailsCookieDecryptor;

#[derive(Debug, Clone, Deserialize)]
pub struct RailsSession {
    pub user_id: i64,
    // any other fields Rails wrote to the session — add as needed
}

pub struct SessionError(&'static str);

impl IntoResponse for SessionError {
    fn into_response(self) -> Response {
        (StatusCode::UNAUTHORIZED, Json(json!({"error": self.0}))).into_response()
    }
}

#[async_trait]
impl<S> FromRequestParts<S> for RailsSession
where
    S: Send + Sync,
    RailsCookieDecryptor: FromRef<S>,
{
    type Rejection = SessionError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let decryptor = RailsCookieDecryptor::from_ref(state);

        // 1. Walk all Cookie headers, find the session cookie by name.
        let session_cookie = parts
            .headers
            .get_all(COOKIE)
            .iter()
            .filter_map(|h| h.to_str().ok())
            .flat_map(Cookie::split_parse)
            .filter_map(Result::ok)
            .find(|c| c.name() == "_app_session")
            .ok_or(SessionError("no session cookie"))?;

        // 2. Decrypt.
        let plaintext = decryptor
            .decrypt(session_cookie.value())
            .map_err(|_| SessionError("invalid session cookie"))?;

        // 3. Parse the JSON session payload.
        //    Rails Devise stores `{"warden.user.user.key":[[user_id], "..."]}`.
        //    Adjust this shape to match what your Rails app actually writes.
        #[derive(Deserialize)]
        struct WardenSession {
            #[serde(rename = "warden.user.user.key")]
            key: (Vec<i64>, String),
        }
        let warden: WardenSession = serde_json::from_str(&plaintext)
            .map_err(|_| SessionError("unrecognized session payload"))?;

        let user_id = warden.key.0.first().copied().ok_or(SessionError("no user id in session"))?;

        Ok(RailsSession { user_id })
    }
}

Now any Axum handler can just take a RailsSession argument and trust that the request came from a browser logged into the Rails app. Same handler ergonomics as AuthUser, different source of truth.

Intermediate: shared session store (the easier middle path)

If Rails stores sessions in Redis (config.session_store :redis_store), you can sidestep the encryption entirely. Rails writes the session blob keyed by a session ID; the cookie contains only the session ID; Rust reads the same Redis key.

async fn fetch_rails_session(
    redis: &redis::Client,
    session_id: &str,
) -> Result<RailsSession, SessionError> {
    let mut conn = redis.get_async_connection().await.map_err(|_| SessionError("redis"))?;
    let key = format!("session:{session_id}");
    let value: Option<Vec<u8>> = redis::cmd("GET")
        .arg(&key)
        .query_async(&mut conn)
        .await
        .map_err(|_| SessionError("redis"))?;

    let bytes = value.ok_or(SessionError("session not found"))?;
    let plaintext = String::from_utf8(bytes).map_err(|_| SessionError("bad utf8"))?;
    // ...same warden parse as above...
}

The cookie still carries the session ID, signed (not encrypted), so you can validate the signature with a much smaller surface than full AES-GCM decryption. For most teams that already use redis-store, this is the path of least resistance.


Advanced: CSRF, gateways, mTLS, per-user limits

Once auth is wired up, the production-hardening list begins.

1. CSRF when you accept cookies

A JWT-only API is largely immune to CSRF (the browser doesn’t automatically attach Authorization headers). But the moment you accept Rails session cookies on POST/PUT/DELETE, you’ve inherited CSRF as a concern. The browser will automatically attach cookies on cross-origin form submissions, and any malicious site can trigger a state-changing request that authenticates as your user.

Two options:

Option A: same-site cookies. Set SameSite=Lax (Rails 7+ default) or SameSite=Strict on the session cookie. The browser refuses to send the cookie on cross-site requests. This alone is usually enough for first-party APIs.

Option B: double-submit token. Rails sets a CSRF token in a non-HttpOnly cookie; the SPA reads it and echoes it as a header (X-CSRF-Token) on every mutating request. The Rust service requires the header to match the cookie.

async fn verify_csrf(parts: &mut Parts) -> Result<(), SessionError> {
    let header_token = parts
        .headers
        .get("x-csrf-token")
        .and_then(|v| v.to_str().ok())
        .ok_or(SessionError("missing csrf header"))?;

    let cookie_token = /* extract `CSRF-TOKEN` cookie by the same pattern as above */;

    // Constant-time comparison — never use `==` on secret tokens.
    use subtle::ConstantTimeEq;
    if header_token.as_bytes().ct_eq(cookie_token.as_bytes()).into() {
        Ok(())
    } else {
        Err(SessionError("csrf mismatch"))
    }
}

subtle::ConstantTimeEq matters more than it looks. A regular == on strings short-circuits at the first mismatched byte, leaking timing information that an attacker can use to guess the token byte-by-byte over many requests. ct_eq always takes the same time regardless of where they differ.

2. The gateway pattern

For teams running on Kubernetes with Envoy/Istio, Cloudflare Access, or AWS API Gateway, there’s a cleaner option: let the gateway do the auth and pass a trusted header to the backend.

Browser ──▶ Envoy ──verify JWT──▶ Rust service (trusts X-User-ID header)

The Rust service no longer touches JWTs or cookies. Instead it trusts a specific header only when the request came from the gateway:

async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
    let user_id = parts
        .headers
        .get("x-user-id")
        .and_then(|v| v.to_str().ok())
        .ok_or(AuthError("missing user header"))?;

    let email = parts
        .headers
        .get("x-user-email")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    Ok(AuthUser { id: user_id.to_string(), email: email.to_string(), claims: /* … */ })
}

The trust boundary here is critical. If your Rust pods are reachable from outside the cluster, anyone can spoof X-User-ID and impersonate any user. Two ways to enforce the boundary:

  • Network policy. Only the gateway’s IP range can reach the Rust pods. Everything else is dropped at the firewall/NetworkPolicy.
  • mTLS. The gateway presents a client certificate that the Rust service verifies. Even if a request reaches the port, it’s rejected without a valid cert.

For internal-only services behind a real cluster gateway, this pattern is the right answer. It centralizes auth, the gateway is the security-critical piece (not every microservice), and the Rust service stays beautifully simple.

3. mTLS for service-to-service

When your Rust service calls other internal services, or when other internal services call it, mTLS gives you mutual identity verification at the connection layer:

use rustls::{ClientConfig, RootCertStore};
use std::sync::Arc;

fn mtls_client(client_cert: &[u8], client_key: &[u8], ca: &[u8]) -> reqwest::Client {
    let mut roots = RootCertStore::empty();
    roots.add_parsable_certificates(rustls_pemfile::certs(&mut std::io::Cursor::new(ca)).unwrap());

    let cert_chain = rustls_pemfile::certs(&mut std::io::Cursor::new(client_cert))
        .unwrap()
        .into_iter()
        .map(rustls::Certificate)
        .collect();
    let key = rustls::PrivateKey(
        rustls_pemfile::pkcs8_private_keys(&mut std::io::Cursor::new(client_key))
            .unwrap()
            .remove(0),
    );

    let config = ClientConfig::builder()
        .with_safe_defaults()
        .with_root_certificates(roots)
        .with_client_auth_cert(cert_chain, key)
        .unwrap();

    reqwest::Client::builder()
        .use_preconfigured_tls(config)
        .build()
        .unwrap()
}

In practice you’d let a service mesh (Linkerd, Istio) issue certificates automatically and the application code stays mTLS-unaware. But it’s good to know what’s actually happening under the mesh: it’s not magic, it’s just TLS with client_auth turned on at both ends.

4. Per-user rate limiting

Anonymous rate limits (IP-based) are easy and rarely useful in microservices behind a load balancer. Authenticated rate limits (per-user) are what you actually want. With our AuthUser extractor and a Redis-backed counter:

async fn rate_limit(user: &AuthUser, redis: &redis::Client) -> Result<(), AppError> {
    let key = format!("ratelimit:{}:{}", user.id, current_minute());
    let mut conn = redis.get_async_connection().await?;

    // INCR returns the new value. EXPIRE on first hit gives a sliding window.
    let count: i64 = redis::cmd("INCR").arg(&key).query_async(&mut conn).await?;
    if count == 1 {
        let _: () = redis::cmd("EXPIRE").arg(&key).arg(60).query_async(&mut conn).await?;
    }

    if count > 100 {
        return Err(AppError::TooManyRequests);
    }
    Ok(())
}

fn current_minute() -> i64 {
    chrono::Utc::now().timestamp() / 60
}

You’d add a TooManyRequests variant to AppError that returns 429 with Retry-After: 60. For more sophisticated limits (token bucket, sliding-log, per-endpoint quotas), look at tower-governor, which plugs directly into the Axum middleware stack.

5. Token revocation

A 15-minute JWT is hard to revoke before expiry: that’s the trade-off you make for stateless verification. Two patterns:

  • Short access + long refresh. The 15-minute access token is uncheckable; the refresh token is server-validated against a Redis (or DB) revocation list. On logout, you invalidate the refresh token. Within 15 minutes, the access token expires and the (now-revoked) refresh token won’t issue a new one.
  • jti deny list. Every JWT carries a jti (token ID). Maintain a Redis set of revoked jtis with TTL = remaining token life. The Rust verifier checks the set on every request.
async fn is_revoked(redis: &redis::Client, jti: &str) -> Result<bool, AppError> {
    let mut conn = redis.get_async_connection().await?;
    let exists: bool = redis::cmd("SISMEMBER")
        .arg("revoked_jtis")
        .arg(jti)
        .query_async(&mut conn)
        .await?;
    Ok(exists)
}

The deny-list approach reintroduces the “every request hits Redis” cost that JWTs were supposed to eliminate, but only for the (rare) revoked tokens, and the SISMEMBER is cheap. For most teams this is a fine compromise.


When not to do any of this in Rust

A reality check, same as previous posts:

  • Sign-up, password reset, MFA: leave in Rails. These flows are full of edge cases (email deliverability, password complexity rules, lockout policies, recovery codes) that you do not want to reimplement. Rust gets a verified identity from Rails; it does not produce one.
  • OAuth providers: leave in Rails too. The Devise omniauth ecosystem is enormous and well-trodden. Your Rust service receives the resulting JWT or session.
  • If you have one endpoint and one client and the client is internal, skip JWT entirely. A shared bearer secret in a Kubernetes secret may be the right answer. Don’t engineer a JWKS rotation pipeline for a service that has three internal callers.

The principle: Rust services consume identity; they don’t produce it. Identity production is best left to the monolith that already has the forms, templates, mailers, fraud-detection hooks, and admin tooling for it.


Where we’re going next

Part 5 will cover deploying a Rust microservice alongside Rails, the boring-but-essential infrastructure: multi-stage Dockerfiles that produce ~20MB images, sharing the database DATABASE_URL between deployments, blue/green and canary patterns with feature flags, observability (Prometheus + OpenTelemetry traces that span Rails and Rust), and the load-balancer config that makes graceful shutdown actually work.

After four posts you have a Rust service that reads (Part 1), reads safely (Part 2), streams (Part 3), and knows who’s calling (this post). Part 5 gets it onto real infrastructure without an outage. That’s the moment a “microservice experiment” becomes a “microservice in production.” And from there, every additional carve-out is a fraction of the work the first one was.

Share this post
Written by Jijo Bose All posts