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.
TLS and mutual TLS
Section titled “TLS and mutual TLS”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 = truecert_path = "/etc/revka/server.pem" # PEM-encoded server certificatekey_path = "/etc/revka/server.key" # PEM-encoded private key| Key | Type | Default | Meaning |
|---|---|---|---|
gateway.tls.enabled | bool | false | Enable application-level TLS |
gateway.tls.cert_path | string | — | Path to the PEM server certificate |
gateway.tls.key_path | string | — | Path to the PEM private key |
Mutual TLS (client certificates)
Section titled “Mutual TLS (client certificates)”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 = trueca_cert_path = "/etc/revka/ca.pem" # CA that signs accepted client certsrequire_client_cert = true| Key | Type | Default | Meaning |
|---|---|---|---|
client_auth.enabled | bool | false | Enable client-certificate verification (mTLS) |
client_auth.ca_cert_path | string | — | CA certificate used to verify client certs |
client_auth.require_client_cert | bool | true | Reject connections that present no valid client cert. Set false for optional client auth |
client_auth.pinned_certs | string[] | [] | SHA-256 fingerprints for certificate pinning |
Certificate pinning
Section titled “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 = trueca_cert_path = "/etc/revka/ca.pem"pinned_certs = [ "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", "AA:BB:CC:DD:EE:FF:...",]Compute a fingerprint to pin with OpenSSL:
openssl x509 -fingerprint -sha256 -noout -in client.pemRate limiting and idempotency
Section titled “Rate limiting and idempotency”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.
Gateway-level rate limiter
Section titled “Gateway-level rate limiter”Tunable in the [gateway] section:
[gateway]pair_rate_limit_per_minute = 10 # 0 disables the pairing rate limitwebhook_rate_limit_per_minute = 60 # 0 disables the webhook rate limitrate_limit_max_keys = 10000 # max tracked IP keys (LRU eviction)idempotency_ttl_secs = 300 # webhook idempotency windowidempotency_max_keys = 10000 # max idempotency keystrust_forwarded_headers = false # see caution below| Key | Type | Default | Meaning |
|---|---|---|---|
gateway.pair_rate_limit_per_minute | integer | 10 | Pairing attempts per minute per IP. 0 disables. |
gateway.webhook_rate_limit_per_minute | integer | 60 | Webhook requests per minute per IP. 0 disables. |
gateway.rate_limit_max_keys | integer | 10000 | Maximum tracked IP keys before LRU eviction |
gateway.idempotency_ttl_secs | integer | 300 | How long a webhook idempotency key dedupes replays |
gateway.idempotency_max_keys | integer | 10000 | Maximum tracked idempotency keys |
gateway.trust_forwarded_headers | boolean | false | Use 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.
Webhook idempotency
Section titled “Webhook idempotency”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.
trust_forwarded_headers
Section titled “trust_forwarded_headers”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 hardware-key authentication
Section titled “WebAuthn hardware-key authentication”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.
Enabling WebAuthn
Section titled “Enabling WebAuthn”WebAuthn is not compiled into default builds — you must build with the webauthn Cargo feature, then enable it in config:
cargo build --release --features webauthn[security.webauthn]enabled = truerp_id = "revka.example.com" # Relying Party ID (a domain name)rp_origin = "https://revka.example.com" # Relying Party origin URLrp_name = "Revka" # display name shown by the authenticator| Key | Type | Default | Meaning |
|---|---|---|---|
security.webauthn.enabled | bool | false | Enable WebAuthn registration & authentication |
security.webauthn.rp_id | string | "localhost" | Relying Party ID — must match the domain serving the dashboard |
security.webauthn.rp_origin | string | "http://localhost:42617" | Relying Party origin URL |
security.webauthn.rp_name | string | "Revka" | Relying Party display name |
Endpoints
Section titled “Endpoints”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.
| Method | Path | Purpose |
|---|---|---|
POST | /api/webauthn/register/start | Begin registration; returns PublicKeyCredentialCreationOptions |
POST | /api/webauthn/register/finish | Complete registration with the authenticator response |
POST | /api/webauthn/auth/start | Begin authentication; returns PublicKeyCredentialRequestOptions |
POST | /api/webauthn/auth/finish | Complete 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/startAuthorization: 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.
Static dashboard file serving (/_app/*)
Section titled “Static dashboard file serving (/_app/*)”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).
| Route | Purpose |
|---|---|
GET /_app/{*path} | Static dashboard assets (JS, CSS, images) |
GET / and SPA fallback | Serves index.html for client-side routing |
Where the dashboard comes from
Section titled “Where the dashboard comes from”The gateway prefers a filesystem path and falls back to the bundle embedded in the binary at build time:
REVKA_WEB_ROOTenvironment variable, if setgateway.web_rootconfig key, if set- The embedded
web/distbundle compiled into the binary
[gateway]web_root = "/srv/revka/web/dist" # optional; serve the dashboard from disk| Source | Key / variable |
|---|---|
| Environment | REVKA_WEB_ROOT |
| Config | gateway.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.
Workspace asset serving (HMAC-signed)
Section titled “Workspace asset serving (HMAC-signed)”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>| Parameter | Meaning |
|---|---|
exp | Unix timestamp expiry; requests after this time return 403 |
sig | Hex 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.
Click-tracking redirect
Section titled “Click-tracking redirect”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 theCLICK_TRACKING_SECRETenvironment variable; the redirect is markedverifiedonly when the secret is set and the HMAC matches.
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).
Claude Code hook endpoint
Section titled “Claude Code hook endpoint”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-codeContent-Type: application/json{ "session_id": "...", "event_type": "...", "tool_name": "...", "summary": "..."}| Field | Meaning |
|---|---|
session_id | The Claude Code session the event belongs to |
event_type | Lifecycle event type (tool execution, completion, error) |
tool_name | Tool the event concerns |
summary | Human-readable event summary |
The handler currently logs the event and returns {"ok": true}.
Endpoint & config summary
Section titled “Endpoint & config summary”| Method & path | Auth | Purpose |
|---|---|---|
POST /api/webauthn/register/start | bearer | Begin WebAuthn registration |
POST /api/webauthn/register/finish | bearer | Complete WebAuthn registration |
POST /api/webauthn/auth/start | bearer | Begin WebAuthn authentication |
POST /api/webauthn/auth/finish | bearer | Complete WebAuthn authentication |
GET /api/webauthn/credentials | bearer | List registered credentials |
DELETE /api/webauthn/credentials/{id} | bearer | Delete a credential |
GET /_app/{*path} | none | Dashboard static assets |
GET /workspace/{*path} | HMAC signature | Signed workspace asset serving |
GET /track/c/{encoded} | none | Click-tracking redirect |
POST /hooks/claude-code | none | Claude Code lifecycle hook receiver |
| Config | Default | Surface |
|---|---|---|
[gateway.tls] enabled | false | Application-level TLS |
[gateway.tls.client_auth] enabled | false | Mutual TLS |
[gateway.tls.client_auth] pinned_certs | [] | Certificate pinning |
gateway.pair_rate_limit_per_minute | 10 | Pairing rate limit |
gateway.webhook_rate_limit_per_minute | 60 | Webhook rate limit |
gateway.trust_forwarded_headers | false | Proxy-aware client keying |
[security.webauthn] enabled | false | WebAuthn (needs webauthn feature) |
gateway.web_root / REVKA_WEB_ROOT | embedded bundle | Dashboard source |
gateway.path_prefix | unset | Sub-path deployment |
CLICK_TRACKING_SECRET (env) | unset | Click-tracking HMAC |
REVKA_GATEWAY_TIMEOUT_SECS (env) | 30 | Request timeout override |
Related pages
Section titled “Related pages”- Gateway API overview — transports, auth model, and route conventions
- Pairing & authentication — bearer tokens, the auth rate limiter, and the service token
- Webhook ingress — the webhook contract, signing, and idempotency
- Realtime: WebSocket, SSE & Live Canvas — where signed workspace asset URLs are consumed
- Network deployment, Raspberry Pi & proxy — reverse-proxy and path-prefix setup
- Run the dashboard — building and serving the web UI
- Security model — bearer vs. service tokens and the secret store