| title | Replay Prevention |
|---|---|
| sidebar_position | 8 |
The gateway provides nonce-based replay prevention to protect against request replay attacks. Clients include a unique X-Nonce header (configurable) with each request, and the gateway rejects any request carrying a previously-seen nonce within a configurable TTL window.
- Client sends a request with a unique nonce value in the
X-Nonceheader - Gateway checks if this nonce has been seen before within the TTL window
- If new: request proceeds, nonce is stored
- If duplicate: request is rejected with
409 Conflict - If missing and
required: true: request is rejected with400 Bad Request
Nonce checking can be configured globally and per route. Per-route settings override global settings field by field.
nonce:
enabled: true
header: "X-Nonce" # header name (default "X-Nonce")
query_param: "" # optional query parameter name (e.g. "nonce")
ttl: 5m # how long nonces are remembered (default 5m)
mode: "local" # "local" (default) or "distributed"
scope: "global" # "global" (default) or "per_client"
required: true # reject requests without nonce (default true)
timestamp_header: "" # optional timestamp header for age validation
max_age: 0 # max request age (requires timestamp_header)routes:
- id: "payments"
path: "/api/payments"
path_prefix: true
backends:
- url: "http://backend:9000"
nonce:
enabled: true
ttl: 10m
scope: "per_client"
required: truePer-route nonce config is merged with the global config. Any field set on the per-route config overrides the corresponding global field. If only the global config is enabled, all routes use global settings.
By default, the nonce is read from the request header (X-Nonce). You can also configure a query parameter as a fallback source using query_param. When both are configured, the header takes precedence — the query parameter is only checked if the header is absent.
nonce:
enabled: true
header: "X-Nonce"
query_param: "nonce" # fallback: read from ?nonce=<value>This allows clients to pass the nonce via URL when headers are inconvenient (e.g., browser redirects, webhook callbacks):
GET /api/callback?nonce=abc123&token=xyz
You can also use query_param alone by leaving header at its default — the header is checked first, and the query parameter is used only when the header is empty.
Nonces are stored in-memory per gateway instance. Fast and zero-dependency, but nonces seen by one instance are not visible to others. Suitable for single-instance deployments or when clients are sticky to a gateway instance.
Nonces are stored in Redis using atomic SET NX PX operations. All gateway instances share the same nonce store, preventing replays across instances.
redis:
address: "localhost:6379"
nonce:
enabled: true
mode: "distributed"
ttl: 5mRedis key pattern: gw:nonce:{routeID}:{nonceKey}
On Redis errors, the gateway fails open (allows the request and logs a warning) to avoid blocking traffic due to Redis outages.
The nonce value alone is the key. A nonce abc123 can only be used once by any client.
The key is {clientID}:{nonce}, where clientID comes from the authenticated identity (variables.Identity.ClientID) with fallback to the client IP. Different clients can reuse the same nonce value.
nonce:
enabled: true
scope: "per_client"Optionally validate request freshness via a timestamp header. Requests older than max_age are rejected with 400 Bad Request before the nonce check.
nonce:
enabled: true
timestamp_header: "X-Timestamp"
max_age: 30sThe timestamp header value is parsed as RFC 3339 (2026-01-15T10:30:00Z) or Unix seconds (1737014400).
The nonce middleware runs at position 6.3 in the middleware chain — after authentication (so Identity.ClientID is available for per_client scope) and before priority admission:
... → 6. authMW → 6.25. extAuthMW → 6.3. nonceMW → 6.5. priorityMW → ...
| Code | Meaning |
|---|---|
400 |
Missing nonce header (when required: true) or invalid/expired timestamp |
409 |
Duplicate nonce (replay detected) |
Responses are JSON:
{"error": "replay detected"}Returns nonce checker status and metrics per route.
curl http://localhost:8081/noncesResponse:
{
"payments": {
"header": "X-Nonce",
"mode": "local",
"scope": "per_client",
"ttl": "5m0s",
"required": true,
"metrics": {
"total_checked": 1500,
"rejected": 3,
"missing_nonce": 12,
"stale_timestamp": 0,
"store_size": 847
}
}
}nonce:
enabled: true
header: "X-Idempotency-Key"
ttl: 24h
mode: "distributed"
scope: "per_client"
required: true
redis:
address: "redis:6379"
routes:
- id: "create-payment"
path: "/api/v1/payments"
methods: ["POST"]
backends:
- url: "http://payments:8080"
nonce:
enabled: trueClient usage:
curl -X POST https://runway.example.com/api/v1/payments \
-H "Authorization: Bearer <token>" \
-H "X-Idempotency-Key: pay_$(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"amount": 1000, "currency": "USD"}'Retrying the same request with the same X-Idempotency-Key within 24 hours returns 409 Conflict.