Rust SDK
The official Rust SDK for API key validation, credit metering, consumer rate limits, and correlated traffic analytics. One crate ships a shared async core, a blocking SyncClient for synchronous applications, and optional native adapters for Axum, Actix Web, Rocket, and Warp — with typed decisions, typed errors, and synchronous resolvers throughout.
Install
Rust 1.88 or newer is required, matching the current Actix Web 4 line. The default build enables the async Client, the blocking SyncClient, and Rustls — framework adapters are opt-in cargo features, so the core never pulls a web framework into your dependency graph:
# Default build: async Client + blocking SyncClient, Rustls TLS, no framework
cargo add reqkey
# Add a framework adapter with its cargo feature
cargo add reqkey --features axum
cargo add reqkey --features actix-web
cargo add reqkey --features rocket
cargo add reqkey --features warp
# Every adapter at once, or platform TLS instead of Rustls
cargo add reqkey --features full
cargo add reqkey --no-default-features --features native-tls,blockingThe SDK reads your project credential from the REQKEY_PROJECT_KEY environment variable (falling back to the legacy REQKEY_ROOT_KEY). Copy the real key from the dashboard, and keep it out of source control:
export REQKEY_PROJECT_KEY="reqkey_your_project_key"Quickstart
Build a Middleware once from a Client and a MiddlewareConfig, then attach it with your framework's native mechanism. Every route it covers is guarded automatically — your handlers stay untouched, and the application never calls /key/validate or /ingest directly.
use axum::{extract::Extension, routing::post, Json, Router};
use reqkey::{
axum::ReqKeyLayer,
middleware::{KeyLocation, Middleware, MiddlewareConfig},
Client, VerificationResult,
};
use serde_json::{json, Value};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_env()?;
let config = MiddlewareConfig::builder("api_payments")
.key_location(KeyLocation::header("X-Startup-Key"))
.exclude_path("/health")
.build()?;
let app = Router::new()
.route("/payments", post(create_payment))
.layer(ReqKeyLayer::new(Middleware::new(client, config)));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn create_payment(Extension(decision): Extension<VerificationResult>) -> Json<Value> {
Json(json!({ "credits_remaining": decision.credits_remaining }))
}Each adapter uses the same shared engine and builder. For Hyper, Tower, or any custom stack, translate your request into a RequestContext, call Middleware::authorize, run the endpoint only for an Authorized/Bypass outcome, then call Middleware::record. For scripts and workers, use the direct Client or the synchronous SyncClient.
Reading the decision
After successful validation, the full VerificationResult — the consumer, credits remaining, and a traceable request id — is stored where each framework keeps request-scoped values:
Extension<VerificationResult> in the handlerrequest.extensions().get::<VerificationResult>()ReqKeyGuard, then call reqkey.decision()WarpRequest::decision() on the filter's guard handleThe decision reads naturally in authorization code:
decision.allowed()— true when the key is valid;decision.reasoncategorizes a denial.decision.credits_remaininganddecision.credits_limit— the consumer's balance (Option<i64>).decision.request_id— a traceable id for support and log correlation.
When fail-open is active, a service failure is exposed instead as ReqKeyFailure in the same request state. Successful responses also carry X-ReqKey-Request-ID, X-ReqKey-Credits-Limit, X-ReqKey-Credits-Remaining, and X-ReqKey-Validation-Time-Ms headers — add them to CORS expose_headers if browser JavaScript must read them.
Modes
One builder call decides what the middleware does per request:
.mode(Mode::Both) // validate keys AND record analytics (default)
.mode(Mode::Validate) // key checks and credit deduction only — nothing recorded
.mode(Mode::Ingest) // analytics only — no key checks at allIn Mode::Both, denied requests — missing keys, exhausted credits, forbidden access, rate-limit hits — are recorded too, so abuse is visible in your dashboards. Call .ingest_denied_requests(false) to record successful requests only. In ingest-only mode, another component's validation can be correlated via .request_id_resolver(...). Ingestion runs inside the request lifecycle — the SDK never spawns a background task that can vanish on shutdown.
Where the key comes from
Match however your consumers already send credentials. The location and its name fuse into a single KeyLocation value:
// Custom header (recommended)
.key_location(KeyLocation::header("X-Startup-Key"))
// Authorization: Bearer <key>
.key_location(KeyLocation::header("Authorization"))
.key_scheme(KeyScheme::Bearer)
// ?api_key=<key> — easy to test, but URLs end up in logs
.key_location(KeyLocation::query("api_key"))
// Cookie — sent automatically by browsers
.key_location(KeyLocation::cookie("startup_key"))
// Or take full control with a synchronous resolver
.consumer_key_resolver(|request| {
request.headers.get("X-Custom-Key").and_then(|v| v.to_str().ok()).map(str::to_owned)
})apiKey identity field.Paths & credit costs
Excluded traffic is never validated, charged, or recorded — for anyone. Credit costs can be the static .credit_cost(n) or a per-request .credit_cost_resolver(...):
// Exact paths or trailing-* prefixes: never validated, charged, or recorded
.exclude_path("/health")
.exclude_path("/docs/*")
// Or decide per request with a predicate
.should_protect(|request| request.path.starts_with("/api/"))
// Charge different endpoints differently (non-negative u64)
.credit_cost_resolver(|request| {
Ok(if request.method == Method::POST { 5 } else { 1 })
})Resolvers are synchronous by design — they inspect already-extracted request metadata and must not perform I/O; put application I/O in the endpoint. The SDK deliberately never auto-retries /key/validate, because validation deducts credits — a safe retry would require server-side idempotency.
Analytics capture
By default each event records the method, normalized endpoint, status, latency, and User-Agent — correlated to the validated consumer. Everything else is opt-in with a plain bool:
MiddlewareConfig::builder("api_payments")
// All off by default — turn on what your dashboards need
.capture_query_params(true)
.capture_request_headers(true)
.capture_response_headers(true)
.capture_response_body(true) // Axum only, textual, first 1,000 chars
.capture_client_ip(true)
// User-Agent is captured by default — opt out explicitly
.capture_user_agent(false)
// Strip additional headers beyond the built-in redaction list
.exclude_header("X-Internal-Trace")
.build()?Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-API-Key, and your configured key name are always stripped before anything leaves your server — even with header capture on. Response-body capture is adapter-dependent: Axum captures a textual body only when the size is known, ≤ 4,004 bytes, and uncompressed; Actix, Rocket, and Warp never consume response bodies. Framework middleware never reads incoming request bodies — supply one explicitly with RequestContext::with_request_body in a plain integration if you need .capture_request_body(true).Failure behavior
Validation fails closed by default: if ReqKey times out or is unreachable, the middleware returns 503 without running your handler. Prefer availability? Set .failure_mode(FailureMode::Open) — transport failures let requests through, while explicitly invalid, exhausted, forbidden, and rate-limited keys are still always denied. Either way, .on_error(...) tells you it happened:
MiddlewareConfig::builder("api_payments")
.failure_mode(FailureMode::Closed) // or FailureMode::Open: let requests through
.on_error(|event| {
// Provider-neutral: post it to Slack, Discord, PagerDuty, ...
tracing::warn!(
"reqkey {:?} failed on {} {}: {}",
event.operation, event.method, event.path, event.error,
);
})
.build()?The event intentionally excludes API keys, project credentials, captured headers, and bodies; panics inside the callback are caught and logged, so alerting can never replace your application response. On the direct client, decisions are normalized (200/402/403/429 are data, not errors) and real failures surface as the typed reqkey::Error — Configuration, Timeout, Transport, Authentication, and Api:
use reqkey::{Client, Error};
match client.verify(key).api_id("api_payments").send().await {
// Access denial is data, not a transport error — inspect the decision.
Ok(decision) => println!("remaining: {:?}", decision.credits_remaining),
// Validation deducts credits, so it is never retried automatically.
Err(Error::Timeout { operation }) => tracing::warn!("reqkey {operation} timed out"),
Err(Error::Api { status, message, .. }) => tracing::error!("reqkey {status}: {message}"),
Err(other) => tracing::error!("reqkey error: {other}"),
}Configuration reference
Every option below is a method on MiddlewareConfig::builder(api_id), chained before .build(). Booleans are plain — the two opt-out captures, capture_user_agent and ingest_denied_requests, default on.
project_keyStringrequiredYour project credential, sent to ReqKey as the Bearer token. Read it from the REQKEY_PROJECT_KEY environment variable — never hard-code it.
api_id&strrequiredThe ReqKey API this application serves — validation and analytics are scoped to it. Passed to MiddlewareConfig::builder.
modeModeWhat the middleware does per request: guard keys and record analytics (the default), key checks only, or analytics only.
enabledboolOne switch to bypass ReqKey entirely — handy as a local-development toggle that leaves your middleware layout untouched.
root_keyStringBackward-compatible alias for project_key on the Client builder. Never set both.
key_locationKeyLocationWhere the key travels. Headers are recommended — query strings end up in logs and browser history.
key_name&strThe customer-facing header, query parameter, or cookie name. Make it yours: X-Startup-Key.
key_schemeKeySchemeFor headers: the raw key value, or a standard Authorization: Bearer token.
consumer_key_resolverresolverFull control — a closure that extracts the key from anywhere in the request.
credit_costu64The static cost per successful request. Defaults to 1; use credit_cost_resolver to price endpoints individually.
credit_cost_resolverresolverCharge heavy endpoints more than light ones — a per-request closure returning the cost.
exclude_path&strPaths that stay public — never validated, charged, or recorded. Exact matches or trailing-* prefixes.
should_protectresolverA per-request predicate for when patterns are not enough — protect exactly the routes you choose.
skip_methodsIntoIterator<Method>HTTP methods that bypass ReqKey. Defaults to [OPTIONS] so CORS preflights stay free.
capture_query_paramsboolRecord redacted query values with each analytics event, appended to the recorded path. Off by default.
capture_request_headersboolRecord request headers — auth headers, cookies, and consumer keys are always stripped first.
capture_response_headersboolRecord response headers, with the same always-on redaction.
capture_response_bodyboolRecord textual response bodies, capped at the first 1,000 characters.
capture_client_ipboolRecord the caller’s IP address as resolved by the framework. Off by default.
capture_user_agentboolRecord the User-Agent header. On by default — call .capture_user_agent(false) to opt out.
exclude_header&strStrip additional headers from captured analytics, beyond the built-in redaction list.
consumer_name_resolverresolverLabel events with a human-readable customer name from a header or another trusted source.
client_ip_resolverresolverBehind a trusted proxy? Tell the SDK exactly which header holds the real client IP.
path_resolverresolverNormalize route names before they are recorded — collapse ids so endpoints group cleanly.
request_id_resolverresolverIn ingest-only mode: correlate events to a validation performed by another middleware.
ingest_denied_requestsboolRecord denied traffic too — invalid keys, exhausted credits, rate-limit hits — so abuse is visible. On by default.
failure_modeFailureModeFail closed (block with a 503, the default) or fail open (let requests through unvalidated). Invalid and exhausted keys are always denied either way.
on_errorcallbackCalled on ReqKey service failures with a provider-neutral event — wire it to Slack, Discord, or PagerDuty. No request secrets included.
error_message(DenialCode, &str)Rewrite the customer-facing denial messages in your own voice, per error type.
timeoutDurationHow long each ReqKey operation may take before the failure mode kicks in. Defaults to 2s. Set on the Client.
Client settings
The project credential and timeout live on the Client, not the middleware config. Client::from_env() reads REQKEY_PROJECT_KEY; for a private deployment, custom timeout, or an injected Reqwest client, use Client::builder() — .project_key(...), .base_url(...) (default https://api.reqkey.com), .timeout(...) (default 2 seconds), and .http_client(...). SyncClient mirrors the same builder for synchronous applications. Never configure both a project and a root key.