Skip to content

TLS, rate limiting, WebAuthn & static serving

Application-level TLS/mTLS with certificate pinning, rate limiting, WebAuthn, dashboard static serving, HMAC-signed workspace assets, path prefix, and click tracking.

This page covers the gateway’s transport-security and serving surface: application-level TLS and mutual TLS with certificate pinning, the gateway rate limiters and webhook idempotency, optional WebAuthn hardware-key authentication, and the four file-serving paths the gateway exposes — the dashboard SPA (/_app/*), HMAC-signed workspace assets (/workspace/*), the click-tracking redirect (/track/c/*), and the Claude Code hook endpoint. Reach for it when you are hardening a gateway for the public internet, putting it behind a reverse proxy, distributing client certificates to trusted services, or wiring the dashboard build into a deployment.

Most of what follows is configuration rather than authenticated endpoints. Bearer-token auth and the pairing flow are covered separately in Pairing & authentication; the cryptographic design behind tokens and secrets is in Secrets, pairing & device auth. For the full route map see the Gateway API overview.

When [gateway.tls] enabled = true, the gateway terminates TLS itself at the application level using rustls — a raw TCP accept loop rather than axum’s plain serve. This is the alternative to terminating TLS at a reverse proxy (nginx, Caddy). Use application-level TLS when you have no proxy in front of the gateway, or when you need mutual TLS down to the process.

[gateway.tls]
enabled = true
cert_path = "/etc/revka/server.pem" # PEM-encoded server certificate
key_path = "/etc/revka/server.key" # PEM-encoded private key
KeyTypeDefaultMeaning
gateway.tls.enabledboolfalseEnable application-level TLS
gateway.tls.cert_pathstringPath to the PEM server certificate
gateway.tls.key_pathstringPath to the PEM private key

Add a [gateway.tls.client_auth] section to require connecting clients to present a certificate signed by a CA you control. This is for operator environments where client certificates are distributed to a known set of trusted services.

[gateway.tls.client_auth]
enabled = true
ca_cert_path = "/etc/revka/ca.pem" # CA that signs accepted client certs
require_client_cert = true
KeyTypeDefaultMeaning
client_auth.enabledboolfalseEnable client-certificate verification (mTLS)
client_auth.ca_cert_pathstringCA certificate used to verify client certs
client_auth.require_client_certbooltrueReject connections that present no valid client cert. Set false for optional client auth
client_auth.pinned_certsstring[][]SHA-256 fingerprints for certificate pinning

When pinned_certs is non-empty, the verifier first runs normal CA validation and then additionally requires the presented client certificate’s SHA-256 fingerprint to match one of the pins. Fingerprints are normalized before comparison — colons are stripped and the value is lowercased — so either form works:

[gateway.tls.client_auth]
enabled = true
ca_cert_path = "/etc/revka/ca.pem"
pinned_certs = [
"aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899",
"AA:BB:CC:DD:EE:FF:...",
]

Compute a fingerprint to pin with OpenSSL:

Terminal window
openssl x509 -fingerprint -sha256 -noout -in client.pem

The gateway runs two independent sliding-window rate limiters keyed per client IP, plus an in-memory idempotency store for webhooks. The auth rate limiter is a brute-force lockout specific to the pairing/auth endpoints (documented in Pairing & authentication); the gateway-level rate limiter described here is a general per-minute limit applied to the pairing and webhook routes.

Tunable in the [gateway] section:

[gateway]
pair_rate_limit_per_minute = 10 # 0 disables the pairing rate limit
webhook_rate_limit_per_minute = 60 # 0 disables the webhook rate limit
rate_limit_max_keys = 10000 # max tracked IP keys (LRU eviction)
idempotency_ttl_secs = 300 # webhook idempotency window
idempotency_max_keys = 10000 # max idempotency keys
trust_forwarded_headers = false # see caution below
KeyTypeDefaultMeaning
gateway.pair_rate_limit_per_minuteinteger10Pairing attempts per minute per IP. 0 disables.
gateway.webhook_rate_limit_per_minuteinteger60Webhook requests per minute per IP. 0 disables.
gateway.rate_limit_max_keysinteger10000Maximum tracked IP keys before LRU eviction
gateway.idempotency_ttl_secsinteger300How long a webhook idempotency key dedupes replays
gateway.idempotency_max_keysinteger10000Maximum tracked idempotency keys
gateway.trust_forwarded_headersbooleanfalseUse X-Forwarded-For / X-Real-IP for the client key

Built-in defaults that are not separately configurable: the rate-limit window is 60 s, the request body cap is 65,536 bytes (64 KiB), and the default request timeout is 30 s (override with the REVKA_GATEWAY_TIMEOUT_SECS environment variable — useful because agentic turns with web search or sub-agent delegation often exceed 30 seconds). Stale rate-limiter entries are swept every 5 minutes to bound memory.

The generic POST /webhook endpoint deduplicates replayed requests by the X-Idempotency-Key header. A key seen again within idempotency_ttl_secs returns 200 without re-running the agent. See Webhook ingress for the full webhook contract.

By default the rate limiters and lockout key on the TCP socket peer address, not on forwarded headers. When trust_forwarded_headers = true, the gateway instead derives the client key from the rightmost hop of X-Forwarded-For / X-Real-IP, which is correct behind a reverse proxy that rewrites all traffic to a single source IP.

WebAuthn / FIDO2 lets you authenticate to the gateway with a hardware security key (YubiKey, SoloKey) or a platform authenticator (Touch ID, Windows Hello), as an alternative or addition to bearer tokens. It is gated behind both a config flag and a compile-time feature.

WebAuthn is not compiled into default builds — you must build with the webauthn Cargo feature, then enable it in config:

Terminal window
cargo build --release --features webauthn
[security.webauthn]
enabled = true
rp_id = "revka.example.com" # Relying Party ID (a domain name)
rp_origin = "https://revka.example.com" # Relying Party origin URL
rp_name = "Revka" # display name shown by the authenticator
KeyTypeDefaultMeaning
security.webauthn.enabledboolfalseEnable WebAuthn registration & authentication
security.webauthn.rp_idstring"localhost"Relying Party ID — must match the domain serving the dashboard
security.webauthn.rp_originstring"http://localhost:42617"Relying Party origin URL
security.webauthn.rp_namestring"Revka"Relying Party display name

All WebAuthn routes require a bearer token. Registration and authentication are two-phase: a start call returns the challenge options the browser passes to the authenticator, and a finish call submits the authenticator’s response.

MethodPathPurpose
POST/api/webauthn/register/startBegin registration; returns PublicKeyCredentialCreationOptions
POST/api/webauthn/register/finishComplete registration with the authenticator response
POST/api/webauthn/auth/startBegin authentication; returns PublicKeyCredentialRequestOptions
POST/api/webauthn/auth/finishComplete authentication with the authenticator assertion
GET/api/webauthn/credentials?user_id=...List registered credentials for a user
DELETE/api/webauthn/credentials/{id}?user_id=...Delete a registered credential
POST /api/webauthn/register/start
Authorization: Bearer rk_<token>
Content-Type: application/json
{ "user_id": "alice", "user_name": "Alice" }

Authentication starts with just the user:

{ "user_id": "alice" }

The finish bodies wrap the challenge plus the authenticator’s RegisterCredentialResponse / AuthenticateCredentialResponse. Listed credentials return credential_id, label, registered_at, and sign_count.

The gateway serves the web dashboard single-page app. Compiled assets are served from /_app/*, and any non-API GET that matches no route falls back to index.html (SPA routing).

RoutePurpose
GET /_app/{*path}Static dashboard assets (JS, CSS, images)
GET / and SPA fallbackServes index.html for client-side routing

The gateway prefers a filesystem path and falls back to the bundle embedded in the binary at build time:

  1. REVKA_WEB_ROOT environment variable, if set
  2. gateway.web_root config key, if set
  3. The embedded web/dist bundle compiled into the binary
[gateway]
web_root = "/srv/revka/web/dist" # optional; serve the dashboard from disk
SourceKey / variable
EnvironmentREVKA_WEB_ROOT
Configgateway.web_root

Asset responses are cached aggressively (Cache-Control: public, max-age=31536000, immutable for files under assets/), while index.html is served no-cache so deploys take effect immediately. Path handling rejects .. traversal, backslash separators, absolute paths, and symlink escapes — the canonical resolved path must stay under the web root.

Path prefix (reverse-proxy sub-path deployments)

Section titled “Path prefix (reverse-proxy sub-path deployments)”

To serve the gateway under a sub-path (for example https://host/revka/), set gateway.path_prefix. When a non-empty prefix is configured, the SPA fallback rewrites /_app/ references inside index.html and injects window.__REVKA_BASE__ so the frontend resolves its asset and API URLs correctly.

[gateway]
path_prefix = "/revka" # must start with "/" and must NOT end with "/"

path_prefix is validated at startup: it must begin with /, must not end with / (a bare / is rejected), and may only contain URL-unreserved and sub-delimiter characters — invalid values fail validation rather than being silently trimmed. See Network deployment, Raspberry Pi & proxy for full reverse-proxy setup.

Generated images, agent outputs, and other artifacts under config.workspace_dir are served at GET /workspace/{*path}. Because browsers don’t attach Authorization headers to subresource fetches (an <img> load, for example), these URLs authenticate with an HMAC-SHA256 signature and an expiry in the query string instead of a bearer token:

GET /workspace/<rel-path>?exp=<unix-ts>&sig=<hex-sha256-hmac>
ParameterMeaning
expUnix timestamp expiry; requests after this time return 403
sigHex HMAC-SHA256 over rel_path + "\n" + exp

The signing key is the gateway’s service token (the same key shared with operator-mcp, read from ~/.revka/service-token), so tools can mint signed URLs without round-tripping through the gateway. The default lifetime is 3600 seconds. Signed URLs are returned relative (/workspace/...), so the same link resolves correctly whether the dashboard is reached locally or through a tunnel. Responses set Cache-Control: private, max-age=300 with a MIME type guessed from the file extension.

Path handling rejects absolute paths, .. traversal, and symlink escapes — the canonical path must stay under workspace_dir. The HMAC prevents tampering with the path, and the expiry prevents long-lived URL hoarding. This path backs agent-generated canvas/HTML content and avatar/asset URLs; see Realtime: WebSocket, SSE & Live Canvas.

The workflow email: step’s track_clicks feature rewrites outbound links to pass through the gateway’s redirect handler, which logs the click and then 302-redirects to the original destination.

GET /track/c/<encoded_kref>?u=<url-encoded-destination>

This endpoint takes no auth by design — cold email recipients have no bearer token. The token in the path encodes the tracked kref:

  • Unsigned: urlsafe_base64(kref) with padding stripped.
  • Signed: urlsafe_base64(kref + ":" + hmac_sha256(secret, kref)[:8]) — the 8-byte HMAC catches tampering while keeping the URL short. Signing is enabled by setting the CLICK_TRACKING_SECRET environment variable; the redirect is marked verified only when the secret is set and the HMAC matches.
Terminal window
export CLICK_TRACKING_SECRET="<your-shared-secret>"

The handler is deliberately fast: clicks are logged via tracing on the hot path with no Kumiho or database write before the redirect ships, because email clients (notably Gmail) prefetch links and abandon slow redirects — it must respond well under 200 ms. A missing ?u= returns 400, but a malformed token still redirects (logging is best-effort and never sacrifices the redirect).

The gateway exposes a lifecycle-hook receiver for Claude Code subprocesses spawned by the ClaudeCodeRunnerTool. These subprocesses cannot obtain a pairing token, so the endpoint requires no bearer auth.

POST /hooks/claude-code
Content-Type: application/json
{
"session_id": "...",
"event_type": "...",
"tool_name": "...",
"summary": "..."
}
FieldMeaning
session_idThe Claude Code session the event belongs to
event_typeLifecycle event type (tool execution, completion, error)
tool_nameTool the event concerns
summaryHuman-readable event summary

The handler currently logs the event and returns {"ok": true}.

Method & pathAuthPurpose
POST /api/webauthn/register/startbearerBegin WebAuthn registration
POST /api/webauthn/register/finishbearerComplete WebAuthn registration
POST /api/webauthn/auth/startbearerBegin WebAuthn authentication
POST /api/webauthn/auth/finishbearerComplete WebAuthn authentication
GET /api/webauthn/credentialsbearerList registered credentials
DELETE /api/webauthn/credentials/{id}bearerDelete a credential
GET /_app/{*path}noneDashboard static assets
GET /workspace/{*path}HMAC signatureSigned workspace asset serving
GET /track/c/{encoded}noneClick-tracking redirect
POST /hooks/claude-codenoneClaude Code lifecycle hook receiver
ConfigDefaultSurface
[gateway.tls] enabledfalseApplication-level TLS
[gateway.tls.client_auth] enabledfalseMutual TLS
[gateway.tls.client_auth] pinned_certs[]Certificate pinning
gateway.pair_rate_limit_per_minute10Pairing rate limit
gateway.webhook_rate_limit_per_minute60Webhook rate limit
gateway.trust_forwarded_headersfalseProxy-aware client keying
[security.webauthn] enabledfalseWebAuthn (needs webauthn feature)
gateway.web_root / REVKA_WEB_ROOTembedded bundleDashboard source
gateway.path_prefixunsetSub-path deployment
CLICK_TRACKING_SECRET (env)unsetClick-tracking HMAC
REVKA_GATEWAY_TIMEOUT_SECS (env)30Request timeout override