Application-layer security middleware for [tower](https://github.com/tower-rs/tower)-based services, powered by the [guard-core-rs](https://github.com/Guard-Core/guard-core-rs) detection engine. Part of the [guard ecosystem](https://github.com/Guard-Core).
Website · Docs · Playground · Dashboard · Discord
Guard Core is the Python engine. Framework adapters are thin wrappers that translate native request/response types into Guard Core's protocols. The telemetry agents ship security events and metrics to the monitoring backend. Parallel engine implementations exist for Go, PHP, TypeScript (on npm), and Rust (on crates.io) - all ports of the same reference semantics, conformance-tested against the shared adversarial corpus.
| Package | Role | PyPI |
|---|---|---|
| guard-core | Framework-agnostic security engine | |
| guard-agent | Telemetry agent | |
| fastapi-guard | FastAPI / Starlette adapter | |
| flaskapi-guard | Flask adapter | |
| djapi-guard | Django adapter | |
| tornadoapi-guard | Tornado adapter |
Go modules published via GitHub releases. Production-ready.
| Package | Role | Release |
|---|---|---|
| guard-core-go | Go engine | |
| nethttp-guard | net/http adapter | |
| gin-guard | Gin adapter | |
| echo-guard | Echo (v4) adapter | |
| fiber-guard | Fiber (v3) adapter | |
| guard-agent-go | Telemetry agent |
Published on Packagist under the rennf93 vendor. Production-ready.
| Package | Role | Packagist |
|---|---|---|
| guard-core-php | PHP engine | |
| laravel-guard | Laravel adapter | |
| symfony-guard | Symfony adapter | |
| psr15-guard | PSR-15 adapter | |
| slim-guard | Slim 4 adapter | |
| guard-agent-php | Telemetry agent |
Published under the @guardcore npm scope; source in the guard-core-ts monorepo. Production-ready.
| Package | Role | npm |
|---|---|---|
| @guardcore/core | Core engine | |
| @guardcore/express | Express adapter | |
| @guardcore/nestjs | NestJS adapter | |
| @guardcore/fastify | Fastify adapter | |
| @guardcore/hono | Hono (edge) adapter | |
| guardagent | Telemetry agent |
Published on crates.io. Production-ready.
| Package | Role | crates.io |
|---|---|---|
| guard-core-engine | Core engine crate | |
| guard-core-rs | Facade crate (consumer entry point) | |
| actix-guard-rs | Actix Web adapter | |
| axum-guard-rs | Axum adapter | |
| tower-guard-rs | Tower adapter | |
| rocket-guard-rs | Rocket adapter | |
| guard-agent-rs | Telemetry agent |
| Package | Role | PyPI |
|---|---|---|
| guard-core-mcp | MCP server: config validation, docs search, detection sandbox |
📚 Documentation - full technical documentation for this package.
🛡️ Guard Core - the engine's reference documentation.
🤖 Monitoring Agent Integration - monitor your Guard instance with a monitoring agent.
The guard ecosystem provides application-layer API security middleware across multiple languages and frameworks:
- Python: fastapi-guard, flaskapi-guard, djapi-guard, tornadoapi-guard
- TypeScript: guard-core-ts with adapters for Express, Fastify, Hono, NestJS
- Rust: guard-core-rs with adapters for tower (this repo), axum, actix-web, and rocket
Per the ecosystem boundary rules, this crate holds framework glue only: every detection decision comes from the engine.
use tower::Layer;
let layer = tower_guard_rs::GuardLayer::new(tower_guard_rs::default_config());
// Any `Service<Request<B>>` works; `axum::Router` is the usual one.
let service = layer.layer(my_service);For axum, axum-guard-rs wraps this layer with with_guard(config). The full crate documentation is in src/lib.rs (build it with cargo doc --open).
One engine call per request view, mirroring the mapping used by the sibling TypeScript adapters:
| Request part | Engine context | Notes |
|---|---|---|
| Path | url_path |
Skipped for / |
| Query string | query_param |
Skipped when empty |
| Header values | header |
Skips sec-* and the negotiation/routing headers (Host, User-Agent, Accept, Accept-Encoding, Connection, Origin, Referer) |
| Body | request_body |
Buffered first, capped |
The HTTP method is not fed to the engine: the engine's detect(content, context, config) takes content plus a context, and the reference adapters do not scan the method either.
Every stateful decision and emission runs through the engine facade's rate-limit stage, configured through GuardLayer builders:
| Surface | Builder / idiom |
|---|---|
| Rate-limit tiers | .with_route_tiers(resolver) (path -> Option<RouteRateLimits>; a RouteRateLimits request extension wins), .with_geo_handler(handler) (geo tiers) |
| Detection exclusions | .with_detection_exclusions(config) (global excluded_detection_headers/params/body_fields, enabled_detection_categories, detection_scan_body); per route via a RouteDetectionExclusions request extension (a non-None route value replaces the global set; headers always merge) |
| Events + log settings | .with_event_bus(bus) (SecurityEventBus hook registration), .with_observability(config) (log_suspicious_level, muted_check_logs, the log_sensitive_headers/params/body_fields redaction sets) |
| Client policy | .with_client_policy(ClientPolicy { trusted_proxies, trusted_proxy_depth, fail_secure }) (builder layers default to no trusted proxies and fail_secure = false; from_security_config installs the config's policy) |
on_block + custom errors |
.with_on_block(hook), .with_custom_error_responses(map) (status-to-body overrides on every block answer, including the 400 detection block) |
| Distributed mode | .with_distributed_store(window_store, prefix, fail_open) + .with_distributed_ban_store(ban_store) (fail-closed backend errors answer 503 Redis rate limiting unavailable) |
| Passive mode | .with_passive_mode(true) (windows and counters still record, log lines and events still fire, no 400/403/429 renders, auto-ban feeds suppressed) |
Scan notes: the query string is scanned as parse_qsl-decoded per-parameter pairs (what makes excluded_detection_params functional), and excluded headers scan with their known false-positive categories suppressed (ssrf for address-chain values) instead of a blanket skip.
GuardLayer::from_security_config(&config) validates the config (SecurityConfig::validate, as the reference does at construction; a rejected value is GuardConfigError::Config) and builds the whole pipeline through the engine facade's Wiring, so every field with an engine-side consumer lands on its stage (the facade's wiring module documents the field-by-field table). from_security_config_with(&config, &WiringOptions { agent, logfire_client }) also injects the telemetry agent: any TelemetryHandler. With the optional agent cargo feature the layer builds the agent itself from the agent_* fields when enable_agent is set (inside a tokio runtime), the reference guard_agent(config.to_agent_config()) step: a failure degrades (agent_stats().degraded) unless agent_strict, which makes it GuardConfigError::Agent. guard-agent-rs depends on the guard-core-rs facade, so the facade cannot own that step and each adapter does. With a sink the event bus, the request metrics and agent_stats() (enabled, degraded) are live, and spawn_dynamic_rules() starts the dynamic-rule poll loop on a std thread (host-started, drop the handle to stop it).
A layer built from a config reads its rate limits, country lists, blocked user agents, cloud providers, emergency mode, penetration toggle and ban knobs from the live config on every request, so a dynamic rule that changes one takes effect on the next request (the recorded rate-limit windows and the ban store carry over). Redis-backed rate limits and bans need the redis cargo feature (it forwards the facade's backend; without it enable_redis keeps per process state) or with_distributed_store. route_resolution_strict answers 500 Route resolution failed for a request carrying the GuardRouteUnresolved extension (the framework glue inserts it for a request it could not route; passive_mode only logs it). body_read_timeout bounds the request-body read once the host installs a timer with with_body_timer (this crate owns no runtime), and a body that does not arrive in time is answered 408 Request body read timed out. The route usage rules run after the pipeline, just before the request is forwarded. log_format and custom_log_file stay on Wiring::config.
The connecting peer is the GuardClientIp request extension. It is resolved through the client policy (trusted proxies, depth, X-Forwarded-For, IPv4-mapped canonicalization) and the resolved client feeds the IP gate, bans, rate limits, geo, cloud, events and the response pass. A request with no peer that cannot be resolved answers 403 Client address could not be determined under fail_secure (the from_security_config default), after firing on_block with the client_address_unresolved check; with fail_secure = false it continues unattributed. The HTTPS stage trusts X-Forwarded-Proto only from a trusted connecting peer.
exclude_paths matches a path or anything under it on a / boundary (/static excludes /static/app.js, never /staticx) after percent-decoding and dot-collapsing. An excluded request runs the reference scoped pipeline: route resolution, bans, the global IP, country and cloud rules and the rate limit still apply; detection, emergency mode, HTTPS enforcement, headers, validators, the user-agent filter and custom checks are skipped. Its response gets metrics, security headers, CORS and the modifier (no return rules), and the request carries the GuardExclusionScoped extension. A route bypassing "all" checks is forwarded as is, with only the response modifier applied (the security_bypass event fires when a bus is installed). The response modifier runs with or without a ResponseProcessor, and return rules see the response body when the processor asks for it (the whole body is buffered and re-emitted, trailers are dropped).
| Situation | Status | Body |
|---|---|---|
| The IP gate denies the client IP | 403 Forbidden |
Forbidden |
| A live ban on the client IP | 403 Forbidden |
IP address banned |
| Rate limit crossed | 429 Too Many Requests (+ Retry-After: <window>) |
Too many requests |
The distributed backend fails with redis_fail_open = false |
503 Service Unavailable |
Redis rate limiting unavailable |
| Engine flags a view | 400 Bad Request |
Suspicious activity detected |
| Engine flags a view and a crossed auto-ban threshold bans on the spot | 403 Forbidden |
IP has been banned |
| Body exceeds the cap | 413 Payload Too Large |
Payload too large |
| Body read error or engine panic | 500 Internal Server Error |
Security check failed |
No resolvable client address under fail_secure |
403 Forbidden |
Client address could not be determined |
The bodies follow the ecosystem's plain-text error convention (the bare message, text/plain; charset=utf-8, same as the Python family), but the adapter is deliberately fail-secure: unlike the TypeScript adapters, whose check pipeline logs and skips on error, any failure to complete the security check answers 500, never an uninspected passthrough.
Engine panics are caught with catch_unwind, so a detected threat or a failed check still produces a response instead of unwinding out of the connection task. panic = "abort" in the release profile disables that recovery.
Request bodies are buffered so the engine can inspect them, and the buffer is bounded. The cap defaults to the engine's full-scan cap (DetectConfig::max_full_scan_bytes, 262 144 bytes in the ecosystem default) and is configurable:
let layer = tower_guard_rs::GuardLayer::new(tower_guard_rs::default_config())
.with_body_cap(1_048_576);A body larger than the cap is rejected with 413 rather than forwarded unscanned: the engine would only ever see a truncated prefix, which would be a bypass vector.
status::GuardStatusService::new(GuardStatus::new().with_cloud_table(table)) is the add_status_route mirror (fastapi-guard guard/status.py + HandlerInitializer.get_initialization_status): a plain tower service answering every request with the initialization snapshot, so the host framework routes it wherever the family default /_guard/status (status::DEFAULT_STATUS_PATH) belongs. The payload carries the cloud-provider status table ({"ready":..., "last_refreshed":<unix-seconds|null>, "entries":N} per provider from the live CloudIpTable) and the geo-ip component (null without a handler, {"configured":true} with the lookup trait only, the {ready, last_refreshed, entries} health snapshot with the IpInfoManager lifecycle manager). The maintenance trio lives on GuardLayer/GuardService: reset() drops the rate-limit windows, refresh_cloud_ip_ranges() schedules one single-flight refresh through with_cloud_refresh_scheduler (the refreshed ranges land in the shared table), and agent_stats() reports whether a telemetry sink is wired and whether it started degraded. axum applications get the mounted route through axum-guard-rs's status::status_router.
WebSocket upgrades are not guarded by this adapter. A tower service sees the upgrade request like any other, but the upgrade itself completes at the hyper connection level (hyper's on_upgrade runtime), outside the tower::Service contract this crate is written against - there is no framework surface here to reject a handshake with the reference's close shapes. The guard exists where the framework exposes the upgrade: axum applications use axum-guard-rs's websocket::WebSocketGuard (1008 policy / 1013 try-again-later close semantics, 403 pre-accept), and actix Web applications use actix-guard-rs's websocket::WebSocketGuard. Plain requests (upgrade or not) still pass through this crate's checks as always.
The Cargo.toml requires guard-core-engine and guard-core-rs at 4.3.2 or newer and carries paths pointing at the sibling guard-core-rs checkout (../guard-core-rs/crates/guard-core-engine, ../guard-core-rs/crates/guard-core-rs) so local builds and CI compile the engine from source. Registry note, stated plainly: the 4.1.0 dists were yanked (the version-accuracy fix for the family tag mistake), so the 1.1.0 of this crate could not resolve its engine from the registry alone; the synchronized 4.2.0 train restores resolution (tower-guard-rs 1.2.0 over guard-core-engine/guard-core-rs 4.2.0). CI checks out Guard-Core/guard-core-rs (see .github/workflows/ci.yml), mirroring the sibling adapter pattern in laravel-guard/symfony-guard.
The adapter now depends on both halves of the sibling checkout: the guard-core-engine crate (detection, rate limiting, bans, exclusions, geo, distributed traits) and the guard-core-rs facade (the event bus, the log/redaction port, the on_block/custom-error contract, and the rate-limit stage the layer delegates to), both with a 4.3.2 floor and path fallbacks into ../guard-core-rs.
Every reference check the engine ships is installable on GuardLayer, and GuardService runs the installed set in the reference pipeline order (provided_layers hands the same stages back as standalone tower layers in that order): emergency mode, HTTPS enforcement, request logging, request size/content caps, required headers + authentication, referrer, custom validators, time windows, geo country blocking, cloud-provider blocking, user-agent filtering, bans, rate limiting, the custom-request check, and the response-side pass (behavioral return rules + security headers + CORS). The builder table lives in the crate docs.
- WebSocket frame guard: a tower service sees a WebSocket upgrade as an ordinary request, so the handshake is screened like any request, but the upgrade completes at the hyper connection level and frames after it are not intercepted. There is no per-frame surface here; see the WebSocket section above for where the guard exists.
- MSRV: 1.92 (matches guard-core-rs); edition 2024
- Requires a sibling
guard-core-rscheckout at../guard-core-rs
cargo check --all-targets
cargo test
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --no-depsCI (.github/workflows/ci.yml) runs the same checks on stable plus an MSRV 1.92 job, checking out guard-core-rs first so the path dependency resolves.
Dual-licensed under MIT or Apache-2.0.