Rustango docs
← Authentication

HMAC request signing

HMAC signing proves both who sent a request and that it wasn't altered in flight. The client signs each request with a shared secret; the server recomputes the signature and compares. Unlike a bearer API key — which is replayable if captured — an HMAC signature covers the method, host, path, query, timestamp, and body, so a tampered or stale request is rejected. It's the scheme AWS SigV4 and webhook signatures use, and Rustango ships it as one tower layer.

HMAC signing in Rustango: the client signs method+path+query+date+body-hash with a shared secret; HmacAuthLayer recomputes and constant-time compares, rejecting tampered or stale requests

New to a term here? HMAC, shared secret, replay, constant-time compare — see the glossary.

Source: rustango::hmac_auth (HmacAuthLayer, KeyResolver, KeyTenantResolver, RequestToSign, sign_now, sign_request) — behind the hmac-auth feature (on by default; replay protection additionally needs cache).

Runnable version: every snippet is copied from auth_hmac_doc.rs (cargo test -p rustango --test auth_hmac_doc).

Table of contents


When to use it

Use…When
API key (Bearer)Simple machine auth; capture risk is acceptable (TLS, short rotation).
HMAC signingYou need per-request integrity + replay resistance — webhooks, partner APIs, anything where a captured request must not be reusable or modifiable.
JWTStateless, self-describing user tokens with claims.

HMAC needs both sides to hold the same secret out-of-band (you provision it), and reasonably synced clocks.


What gets signed

The client builds a canonical string and HMAC-SHA256s it with the shared secret:

<UPPERCASE-METHOD>\n
<LOWERCASE-HOST>\n
<PATH>\n
<SORTED-QUERY>\n
<X-DATE>\n
<HEX-SHA256(BODY)>

Two request headers carry the result:

  • X-Date — an RFC 3339 timestamp (also part of the signed string).
  • Authorization: HMAC-SHA256 keyId=<id>,signature=<base64>

Because the query is sorted on both ends, ?b=2&a=1 and ?a=1&b=2 produce the same signature. Because the body is hashed into the string, changing a single byte invalidates it.

The host is the Host header without its port. Unpinned, the layer trusts the request's own Host, so a request replayed to another service that shares the key, with Host still naming the first, passes. When services share a key, pin each one with .host("api.example.com") or put it behind a proxy that enforces Host. Pin it too behind a proxy that rewrites Host.


Server: verify with the layer

HmacAuthLayer::new takes a KeyResolver — a closure mapping a keyId to its secret (None ⇒ unknown key ⇒ 401). Attach it as a normal tower layer in front of the routes you want to protect:

use std::sync::Arc;
use rustango::hmac_auth::{HmacAuthLayer, KeyResolver};
use tower::Layer;

// Resolve key ids to secrets — back this with your DB / secret store.
let resolver: KeyResolver = Arc::new(|key_id: &str| {
    (key_id == "k_demo").then(|| b"shared-secret-at-least-32-bytes-long!!".to_vec())
});

let layer = HmacAuthLayer::new(resolver)
    .tolerance_secs(300);                 // ±5 min clock-skew window (default)

let app = protected_router.layer(layer);

A correctly-signed request passes; tamper with the body, drop X-Date, or sign with an unknown key and it's a 401:

// correctly signed            → 200
// body changed after signing  → 401  (signature mismatch)
// missing X-Date header       → 401
// keyId the resolver rejects  → 401

No identity extractor. The layer verifies the signature but does not inject which keyId signed into the request — there's no HmacUser extractor. If a handler needs the caller identity, wrap the layer or carry it yourself. Rejections are plain 401/413 responses, not a typed error you match on.


Client: sign a request

sign_now signs with the current time and returns the two header values to attach (sign_request is the variant that takes an explicit RFC 3339 date):

use rustango::hmac_auth::sign_now;

let body = br#"{"amount": 100}"#;
let (x_date, authorization) =
    sign_now("k_demo", b"shared-secret-at-least-32-bytes-long!!",
             "POST", "api.example.com", "/api/charge", "", body);

// Attach both headers and send the EXACT body you signed:
let req = http::Request::post("/api/charge")
    .header("x-date", x_date)
    .header("authorization", authorization)
    .body(body.to_vec())?;

The signature is base64; the body-hash inside the canonical string is hex. Send the body byte-for-byte as signed — any proxy that rewrites it (recompression, JSON re-serialization) breaks verification.


Multi-tenant apps

The tenant header and the port are not signed by default, and the layer knows no tenant. On a tenant-resolved app:

  • .bind_tenants(tenant_of) maps each key id to the tenant slug it may act for. The layer resolves the request's tenant with the mounted tenant context and answers 403 when it differs, or when there is none.
  • .signed_headers(["x-org"]) adds headers to the signed string, and .sign_port() adds the port in Host. Each adds a name:value line, sorted by name. The client signs the same list with RequestToSign::header and port; a header the request leaves out signs as empty.

Clock skew and replay

The X-Date timestamp bounds replay: a request whose date is outside tolerance_secs (default ±300 s) is rejected, so a captured request is only reusable inside that short window. To close it entirely, attach a nonce store (any cache::Cache) and each signature can be spent only once within the window:

use rustango::cache::InMemoryCache;

let layer = HmacAuthLayer::new(resolver)
    .tolerance_secs(120)
    .nonce_store(Arc::new(InMemoryCache::new()));  // reject replays

In production use a shared store (Redis) so the protection holds across replicas — an in-process cache only guards one instance. The replay check fails open on a cache error (availability over the narrow in-window risk).


Limits

  • Symmetric ±skew, RFC 3339 dates. Both clocks must be roughly synced; the client must send the same timestamp it signed (sign_now returns it for you).
  • Full body buffering. The body is read into memory to hash it (default cap 10 MiB → 413; raise with .body_limit(n) but mind memory). Streaming bodies aren't supported.
  • Signature is base64 on the wire, body-hash is hex — easy to mix up when writing a client in another language.
  • Keep the layer outermost relative to anything that mutates the body.

See also

  • API keys — simpler bearer credential when integrity/replay aren't a concern.
  • Auth backends — for identifying a user per request (HMAC proves message integrity, not a session identity).
  • Webhooks — the inbound counterpart: verifying signatures on events you receive.
  • Middleware — how tower layers attach and order.