| title | Security |
|---|---|
| sidebar_position | 1 |
The gateway provides trusted proxy handling, IP filtering, geo filtering, CORS handling, a web application firewall (WAF), request body size limits, replay prevention, CSRF protection, backend request signing, security response headers, and custom DNS resolution for defense-in-depth security.
When the gateway runs behind a load balancer, CDN, or reverse proxy, the real client IP is in the X-Forwarded-For or X-Real-IP headers — not in the TCP connection address. Without trusted proxy configuration, any client can spoof these headers and bypass IP-based security features (rate limiting, IP filtering, geo filtering).
Configure trusted_proxies to tell the gateway which upstream proxies to trust. The gateway walks the X-Forwarded-For chain from right to left, skipping trusted proxy IPs, and uses the first untrusted IP as the real client IP.
trusted_proxies:
cidrs:
- "10.0.0.0/8" # internal network
- "172.16.0.0/12" # Docker networks
- "192.168.0.0/16" # private networks
- "127.0.0.1" # loopback
headers: # headers to check (default: X-Forwarded-For, X-Real-IP)
- "X-Forwarded-For"
- "X-Real-IP"
max_hops: 3 # max proxy hops to walk back (0 = unlimited)- Check RemoteAddr — If the direct TCP connection source does not match any trusted CIDR, the gateway uses
RemoteAddras the client IP (ignoring all headers). This prevents spoofing from untrusted sources. - Walk XFF chain — If
RemoteAddris trusted, the gateway reads the configured headers (default:X-Forwarded-For, thenX-Real-IP) and walks theX-Forwarded-Forchain from right to left, skipping IPs that match trusted CIDRs. - Return first untrusted IP — The first IP in the chain that does NOT match a trusted CIDR is the real client IP.
All IP-based features automatically use the extracted real IP:
- Rate limiting — limits apply to the real client, not the load balancer
- IP filtering — allow/deny rules evaluate against the real client IP
- Geo filtering — country lookups use the real client location
- WAF — IP-based rules see the real client
- Rules engine —
ip.srcresolves to the real client IP - Access logging — logs show the real client IP
When trusted_proxies is not configured, the gateway uses legacy behavior: it trusts the first entry in X-Forwarded-For unconditionally. This is acceptable when the gateway is the internet-facing edge (no upstream proxies), but is insecure when behind a load balancer because clients can spoof the header.
- All entries in
cidrsmust be valid CIDR notation (e.g.,10.0.0.0/8) or bare IP addresses (e.g.,127.0.0.1) max_hopsmust be >= 0
Allow or deny requests by client IP using CIDR blocks. IP filtering can be configured globally and per route — both are evaluated (global first, then per-route). The order field controls evaluation order: allow_first checks allow rules before deny rules (default), while deny_first checks deny rules first.
# Global IP filter
ip_filter:
enabled: true
order: "deny_first" # "allow_first" (default) or "deny_first"
allow:
- "10.0.0.0/8"
- "192.168.0.0/16"
deny:
- "10.0.0.100/32"
# Per-route override
routes:
- id: "admin"
path: "/admin"
path_prefix: true
backends:
- url: "http://backend:9000"
ip_filter:
enabled: true
allow:
- "10.0.1.0/24"Denied requests receive 403 Forbidden.
Block or allow requests based on the client's geographic location using MaxMind (.mmdb) or IPIP (.ipdb) databases. Geo filtering can be configured globally and per route — both are evaluated (global first, then per-route). The middleware also injects X-Geo-Country and X-Geo-City headers for downstream services.
# Global geo config
geo:
enabled: true
database: "/etc/runway/GeoLite2-City.mmdb"
inject_headers: true # inject X-Geo-Country / X-Geo-City
deny_countries:
- "CN"
- "RU"
order: "deny_first" # "deny_first" (default) or "allow_first"
# Per-route override
routes:
- id: "api"
path: "/api"
path_prefix: true
backends:
- url: "http://backend:9000"
geo:
enabled: true
allow_countries:
- "US"
- "CA"
- "GB"
order: "deny_first"The order field controls evaluation order:
- deny_first (default): Check deny rules first — if matched, deny. Then check allow rules — if allow lists are non-empty and not matched, deny. Otherwise allow.
- allow_first: Check allow rules first — if allow lists are non-empty and matched, allow. Then check deny rules — if matched, deny. Otherwise allow.
Country codes must be ISO 3166-1 alpha-2 (e.g. US, DE, CN). City names are case-insensitive.
Use shadow_mode: true to log geo decisions without rejecting traffic — useful for testing rules before enforcement:
geo:
enabled: true
database: "/etc/runway/GeoLite2-City.mmdb"
deny_countries: ["CN"]
shadow_mode: true| Format | Extension | Library |
|---|---|---|
| MaxMind GeoIP2/GeoLite2 | .mmdb |
oschwald/maxminddb-golang/v2 |
| IPIP | .ipdb |
ipipdotnet/ipdb-go |
Denied requests receive 451 Unavailable For Legal Reasons with a JSON body.
Configure Cross-Origin Resource Sharing headers and preflight handling per route:
routes:
- id: "api"
path: "/api"
path_prefix: true
backends:
- url: "http://backend:9000"
cors:
enabled: true
allow_origins:
- "https://app.example.com"
allow_origin_patterns: # regex patterns
- "https://.*\\.example\\.com"
allow_methods: ["GET", "POST", "PUT", "DELETE"]
allow_headers: ["Authorization", "Content-Type"]
expose_headers: ["X-Request-ID"]
allow_credentials: true
allow_private_network: true # Access-Control-Allow-Private-Network
max_age: 3600 # preflight cache (seconds)Preflight (OPTIONS) requests are handled automatically and do not reach the backend.
The WAF uses the Coraza engine with ModSecurity-compatible rules. It can operate in block mode (reject malicious requests) or detect mode (log only).
WAF can be configured globally and per route:
# Global WAF
waf:
enabled: true
mode: "block"
sql_injection: true # built-in SQLi protection
xss: true # built-in XSS protection
rule_files:
- "/etc/runway/waf/custom-rules.conf"
inline_rules:
- 'SecRule REQUEST_URI "@contains /admin" "id:1001,phase:1,deny,status:403"'
# Per-route WAF
routes:
- id: "api"
path: "/api"
path_prefix: true
backends:
- url: "http://backend:9000"
waf:
enabled: true
mode: "detect" # log-only for this routeThe sql_injection and xss shortcuts enable curated rule sets without requiring external rule files.
Limit the maximum request body size per route:
routes:
- id: "upload"
path: "/upload"
path_prefix: true
backends:
- url: "http://backend:9000"
max_body_size: 10485760 # 10 MB (bytes)Requests exceeding the limit receive 413 Request Entity Too Large.
Override the system DNS resolver for backend connections. Useful for service mesh environments or split-horizon DNS:
dns_resolver:
nameservers:
- "10.0.0.53:53"
- "10.0.0.54:53"
timeout: 5sAll backend connections use the custom resolver when configured.
Prevent request replay attacks using nonce-based deduplication. Clients include a unique value in the X-Nonce header (configurable), and the gateway rejects duplicate nonces within a TTL window.
nonce:
enabled: true
header: "X-Nonce"
query_param: "nonce" # optional: also check ?nonce=<value>
ttl: 5m
mode: "local" # "local" or "distributed" (Redis)
scope: "global" # "global" or "per_client"
required: trueThe nonce is read from the header first; if absent and query_param is set, the query parameter is checked as a fallback. Duplicate requests receive 409 Conflict. Missing nonces receive 400 Bad Request when required: true. Optional timestamp validation via timestamp_header and max_age rejects stale requests.
See Replay Prevention for full documentation including distributed mode, per-client scope, and timestamp validation.
Prevent cross-site request forgery attacks using the double-submit cookie pattern with HMAC-signed tokens. State-changing requests must include a matching token in both a cookie and request header.
csrf:
enabled: true
secret: "${CSRF_SECRET}"
cookie_secure: true
inject_token: true
allowed_origins:
- "https://app.example.com"Optional Origin/Referer validation and shadow mode for gradual rollout. See CSRF Protection for full documentation.
| Field | Type | Description |
|---|---|---|
ip_filter.enabled |
bool | Enable IP filtering |
ip_filter.allow |
[]string | Allowed CIDR blocks |
ip_filter.deny |
[]string | Denied CIDR blocks |
ip_filter.order |
string | allow_first (default) or deny_first |
geo.enabled |
bool | Enable geo filtering |
geo.database |
string | Path to .mmdb or .ipdb file (global only) |
geo.inject_headers |
bool | Inject X-Geo-Country/X-Geo-City headers |
geo.allow_countries |
[]string | Allowed country codes (ISO 3166-1 alpha-2) |
geo.deny_countries |
[]string | Denied country codes |
geo.allow_cities |
[]string | Allowed city names (case-insensitive) |
geo.deny_cities |
[]string | Denied city names |
geo.order |
string | deny_first (default) or allow_first |
geo.shadow_mode |
bool | Log but don't reject |
cors.allow_origin_patterns |
[]string | Regex origin patterns |
cors.allow_private_network |
bool | Private network access header |
waf.mode |
string | block or detect |
waf.sql_injection |
bool | Enable built-in SQLi rules |
waf.xss |
bool | Enable built-in XSS rules |
max_body_size |
int64 | Max request body (bytes) |
dns_resolver.nameservers |
[]string | DNS servers (host:port) |
nonce.enabled |
bool | Enable replay prevention |
nonce.header |
string | Nonce header name (default X-Nonce) |
nonce.ttl |
duration | Nonce TTL (default 5m) |
nonce.mode |
string | local (default) or distributed |
nonce.scope |
string | global (default) or per_client |
nonce.required |
bool | Reject missing nonce (default true) |
The gateway can present client certificates when connecting to backends that require mutual TLS authentication. This is common for internal microservices, payment APIs, and partner integrations.
Configure client certificates globally or per-upstream via the transport block:
# Global — all upstreams present this client cert
transport:
ca_file: /etc/runway/internal-ca.pem
cert_file: /etc/runway/client.crt
key_file: /etc/runway/client.key
# Per-upstream — specific cert for a single upstream
upstreams:
payment-api:
backends:
- url: https://payments.internal:443
transport:
cert_file: /etc/runway/payment-client.crt
key_file: /etc/runway/payment-client.key
ca_file: /etc/runway/payment-ca.pemBoth cert_file and key_file must be specified together. Per-upstream settings override global settings. See Transport for details.
While listener-level client_auth applies one CA pool to all routes, per-route client mTLS lets different routes trust different Certificate Authorities. For example, /payments can require certificates from Payment-CA while /internal trusts Internal-CA.
- Configure the listener to request (not verify) client certificates:
listeners:
- id: https
address: ":8443"
protocol: http
tls:
cert_file: /etc/runway/server.crt
key_file: /etc/runway/server.key
client_auth: "request"- Add
client_mtlsto individual routes:
routes:
- id: payments
path: /payments
backends:
- url: https://payments.internal:443
client_mtls:
enabled: true
client_ca_file: /etc/runway/payment-ca.pem
- id: internal
path: /internal
backends:
- url: https://internal.svc:443
client_mtls:
enabled: true
client_cas:
- /etc/runway/internal-ca1.pem
- /etc/runway/internal-ca2.pemRoutes without client_mtls remain accessible without a client certificate.
HTTP/2 does not support TLS renegotiation, so the listener must request certificates from all clients during the initial handshake. Clients connecting to non-mTLS routes will be asked for a certificate but not rejected for omitting one.
See Client mTLS for full configuration reference and admin endpoint.
The gateway can sign every outgoing request so backends can verify that requests actually came through the gateway and weren't tampered with. This prevents "backend bypass" attacks where clients send requests directly to backend services. Both HMAC and RSA algorithms are supported.
- The runway reads the request body (for POST/PUT/PATCH/DELETE) and computes its SHA-256 hash
- A signing string is built from the method, path+query, timestamp, body hash, and any configured signed headers
- An HMAC is computed over the signing string using the configured algorithm and shared secret
- Four headers are injected into the outgoing request
Newline-separated fields:
POST
/api/v1/users?page=2
1707654321
e3b0c44298fc1c14... (SHA-256 hex of body)
content-type:application/json
host:api.example.com
| Header | Example | Description |
|---|---|---|
X-Runway-Signature |
hmac-sha256=a1b2c3... |
Signature hex digest with algorithm prefix |
X-Runway-Timestamp |
1707654321 |
Unix seconds when signature was created |
X-Runway-Key-ID |
runway-key-1 |
Key identifier for rotation |
X-Runway-Signed-Headers |
content-type;host |
Semicolon-separated list of signed headers |
The header prefix is configurable via header_prefix (default X-Runway-).
# Global — HMAC signing (shared secret)
backend_signing:
enabled: true
algorithm: "hmac-sha256" # hmac-sha256, hmac-sha512, rsa-sha256, rsa-sha512, rsa-pss-sha256
secret: "base64-encoded-secret-at-least-32-bytes"
key_id: "runway-key-1"
signed_headers: # optional: headers to include
- "Content-Type"
- "Host"
include_body: true # default true
header_prefix: "X-Runway-" # default "X-Runway-"
# RSA signing (asymmetric — backends verify with public key)
backend_signing:
enabled: true
algorithm: "rsa-sha256"
private_key_file: /etc/runway/signing-key.pem # or inline via private_key:
key_id: "runway-rsa-1"
# Per-route override
routes:
- id: "payments"
path: "/api/payments"
backends:
- url: "http://payments:8080"
backend_signing:
enabled: true
key_id: "payments-key-2" # override key for this route
algorithm: "rsa-sha512" # RSA for asymmetric verificationUse key_id to support key rotation. Deploy the new key to backends first, then update the runway config. Backends should accept signatures from any known key ID during the transition period.
def verify_request(request, secrets):
key_id = request.headers["X-Runway-Key-ID"]
secret = secrets[key_id]
timestamp = request.headers["X-Runway-Timestamp"]
# Reject stale signatures (e.g., > 5 minutes)
if abs(time.now() - int(timestamp)) > 300:
return False
# Rebuild signing string
body_hash = sha256(request.body).hex()
signing_str = f"{request.method}\n{request.path_and_query}\n{timestamp}\n{body_hash}"
signed_headers = request.headers["X-Runway-Signed-Headers"]
if signed_headers:
for header in signed_headers.split(";"):
signing_str += f"\n{header.lower()}:{request.headers[header]}"
# Verify HMAC
algo, sig = request.headers["X-Runway-Signature"].split("=", 1)
expected = hmac(secret, signing_str, algorithm=algo).hex()
return constant_time_compare(sig, expected)Automatically inject security-related HTTP response headers on every response. This provides defense-in-depth without requiring rules engine expressions or manual header transforms. Configurable globally and per route (per-route overrides global).
# Global security headers — applied to all routes
security_headers:
enabled: true
strict_transport_security: "max-age=31536000; includeSubDomains"
content_security_policy: "default-src 'self'"
x_content_type_options: "nosniff" # default when omitted
x_frame_options: "DENY"
referrer_policy: "strict-origin-when-cross-origin"
permissions_policy: "camera=(), microphone=(), geolocation=()"
cross_origin_opener_policy: "same-origin"
cross_origin_embedder_policy: "require-corp"
cross_origin_resource_policy: "same-origin"
x_permitted_cross_domain_policies: "none"
custom_headers:
X-Custom-Security: "enabled"Per-route overrides:
routes:
- id: "web-app"
path: "/app"
path_prefix: true
backends:
- url: "http://backend:9000"
security_headers:
enabled: true
x_frame_options: "SAMEORIGIN" # override global DENY
content_security_policy: "default-src 'self'; script-src 'self' cdn.example.com"| Config Field | HTTP Header | Default |
|---|---|---|
strict_transport_security |
Strict-Transport-Security |
— |
content_security_policy |
Content-Security-Policy |
— |
x_content_type_options |
X-Content-Type-Options |
nosniff |
x_frame_options |
X-Frame-Options |
— |
referrer_policy |
Referrer-Policy |
— |
permissions_policy |
Permissions-Policy |
— |
cross_origin_opener_policy |
Cross-Origin-Opener-Policy |
— |
cross_origin_embedder_policy |
Cross-Origin-Embedder-Policy |
— |
cross_origin_resource_policy |
Cross-Origin-Resource-Policy |
— |
x_permitted_cross_domain_policies |
X-Permitted-Cross-Domain-Policies |
— |
custom_headers |
Any | — |
X-Content-Type-Options: nosniff is always injected by default (even with no explicit value). All other headers are only sent when explicitly configured. Use custom_headers for arbitrary extra response headers.
Per-route metrics (total requests served, header count) are available via the /security-headers admin endpoint.
See Configuration Reference for all fields.
Automatically redirects HTTP requests to HTTPS. Runs in the global handler chain, before route matching.
https_redirect:
enabled: true
port: 443 # target HTTPS port (default 443)
permanent: false # true=301, false=302 (default false)The middleware checks both r.TLS and the X-Forwarded-Proto header, so it works correctly behind TLS-terminating load balancers. When port is 443, the port is omitted from the redirect URL for cleaner URLs.
Admin endpoint: GET /https-redirect returns redirect statistics.
Note: HTTPS redirect is part of the global handler chain, which is not rebuilt on config reload. Changes to https_redirect require a runway restart.
Validates the Host header against a whitelist. Rejects requests to unknown hosts with 421 Misdirected Request.
allowed_hosts:
enabled: true
hosts:
- "api.example.com"
- "*.internal.example.com" # wildcard: matches any subdomainExact hosts use O(1) map lookups. Wildcard patterns (*.example.com) match any subdomain via suffix comparison. The port is stripped before matching.
Admin endpoint: GET /allowed-hosts returns the host list and rejection count.
Note: Like HTTPS redirect, allowed hosts is part of the global handler chain and requires a restart to change.
Verifies signatures on incoming requests — the inverse of outbound backend signing. Use this to authenticate webhook receivers, signed API clients, and inter-service calls.
Supports HMAC (hmac-sha256, hmac-sha512) and RSA (rsa-sha256, rsa-sha512, rsa-pss-sha256) algorithms. The signing string format matches the outbound signing protocol: METHOD\nURI\nTIMESTAMP\nSHA256(BODY)[\nheader:value...].
# HMAC verification (shared secret)
inbound_signing:
enabled: true
algorithm: hmac-sha256
secret: "base64-encoded-32-byte-key"
max_age: 5m
shadow_mode: false
# RSA verification (asymmetric — clients sign with private key)
inbound_signing:
enabled: true
algorithm: rsa-sha256
public_key_file: /etc/runway/client-public.pem # or inline via public_key:
max_age: 5mShadow mode logs verification failures without rejecting requests — useful for gradual rollout.
Admin endpoint: GET /inbound-signing returns per-route verification stats.
See Inbound Signing for full documentation.
Pattern-based detection and masking of sensitive data (emails, credit card numbers, SSNs, phone numbers) in request and response bodies and headers.
routes:
- id: my-route
pii_redaction:
enabled: true
built_ins: [email, credit_card, ssn, phone]
scope: responseSupports custom regex patterns and configurable mask characters. Only processes text-like Content-Types.
Admin endpoint: GET /pii-redaction returns per-route redaction stats.
See PII Redaction for full documentation.
AES-GCM-256 encryption of specific JSON fields in requests before forwarding and decryption in responses before returning to the client.
routes:
- id: my-route
field_encryption:
enabled: true
algorithm: aes-gcm-256
key_base64: "base64-encoded-32-byte-key"
encrypt_fields: [password, ssn]
decrypt_fields: [encrypted_data]Uses gjson paths for field selection. Nonces are generated per-encryption from crypto/rand.
Admin endpoint: GET /field-encryption returns per-route encryption stats.
See Field Encryption for full documentation.