Security hardening for public traffic

The checks to make before exposing a deployment — unauthenticated paths, rate limits, the auth server's public surface, secrets, module signing, header trust and internal endpoints.

Know the unauthenticated surface#

PathServiceProtection
/actuator/health/*allper-IP limit
/api/webhooks/{flowId}gateway → workerthe flow id is the secret unless the flow sets triggerConfig.webhook.secret (header or signed query); per-IP limit
/api/modules/webhooks/{tenantId}/{moduleId}gateway → workerthe module verifies the signature; per-IP limit
/api/webhooks/mail/**gateway → workerinbound mail (optional feature); per-IP limit
/api/webhooks/sesgateway → workerAmazon SNS signature on bounce and complaint notifications
/api/track/**gateway → workercampaign open, click and unsubscribe links; the HMAC tracking token
/api/telehealth/visits/**, /api/telehealth/webhooks/**gateway → workervisit links: their signed token; LiveKit webhooks: the signed JWT and body digest (optional feature)
/scim/**gateway → workerthe worker checks the SCIM bearer token (SCIM)
/otel/**gateway → collectorbrowser telemetry export
/portal/**authmagic-link and signup pages; per-IP limits in the auth server; optional bot challenge
/oauth2/**, /login/**, /saml2/**authstandard OAuth/SAML endpoints

Everything else under /api/** requires a credential and API_ACCESS, except GET on the UI bootstrap paths (/api/ui-pages, /api/ui-menus, /api/ui-translations, /api/oidc-providers, /api/tenants), which the sign-in and app shell read before there is a session; they are served only for the tenant in the URL. The full list, with each endpoint’s permission, is the public endpoint index.

Gateway limits#

  • RATE_LIMIT_IP_PATHS — per-IP budgets on the public prefixes above. Tune per endpoint; each prefix is its own bucket. Default /actuator/health=100,/api/modules/webhooks=300,/api/webhooks=300,/api/webhooks/mail=600,/api/webhooks/ses=600 (requests per minute per IP). /api/webhooks covers flow webhooks, and all of them called from one IP share that one bucket. Setting the variable replaces the whole list, so keep every entry you still want.
  • The tenant window is the tenant’s apiCallsPerDay; RATE_LIMIT_USER_SHARE caps one member. Tighten the share for tenants with many members.
  • RATE_LIMIT_EXEMPT_CIDRS bypasses both limiters. Never list private ranges in production; keep it to monitoring probes and known webhook egress ranges.
  • Limiters are Redis-backed and fail open when Redis is unreachable — monitor Redis.

Client IP and trusted proxies#

Set KELTA_SECURITY_TRUSTED_PROXIES on the gateway and the auth server to the pod CIDR of your ingress controller (comma-separated CIDRs or bare IPs, e.g. 10.42.0.0/16). Kelta then believes X-Forwarded-For and X-Real-IP only when the connection comes from one of those addresses, and takes the client to be the right-most hop that is not a trusted proxy. That address is what the security audit log, login history (login_history.source_ip), geolocation, rate limits and tenant IP allowlists all use; the gateway passes it to the worker in a header it sets itself.

Your ingress must preserve the client address (for example externalTrafficPolicy: Local, or PROXY protocol from the load balancer), or every request will look like it came from the node.

Left empty (the default), tenant IP allowlists are strict: they judge only the address of the connection the gateway received (the socket peer) and ignore X-Forwarded-For / X-Real-IP. Behind an ingress that address is the ingress pod, so a restricted tenant’s non-admin users are refused until you set KELTA_SECURITY_TRUSTED_PROXIES (users with MANAGE_TENANTS still get through). Audit logs, geolocation and rate limits keep the earlier behaviour: with IP_ALLOWLIST_TRUST_XFF=true the left-most X-Forwarded-For hop is treated as the client. That hop is client-controlled, so a caller can put any address they like in the audit trail and get a fresh rate-limit bucket per request; setting IP_ALLOWLIST_TRUST_XFF=false closes that but makes every client look like the ingress. Setting trusted proxies fixes both.

Auth server#

  • DIRECT_LOGIN_ENABLED=false.
  • Sign in as the platform admin (admin@kelta.local, tenant default) and change the first-boot password before the deployment takes traffic. kelta-auth replaces the database’s seeded password on first boot — with KELTA_BOOTSTRAP_ADMIN_PASSWORD, or a generated one printed once in its log — and forces a change at first sign-in; until then anyone holding the bootstrap value or that log line can become platform admin. Remove the variable from your secret store afterwards, and treat the auth log of the first boot as sensitive.
  • Set KELTA_SECURITY_TRUSTED_PROXIES (above). Without it, KELTA_AUTH_RATE_LIMIT_TRUSTED_PROXY_COUNT must equal the number of proxies in front of the auth server (0 if none); otherwise a client can forge X-Forwarded-For and get a fresh limit bucket per request.
  • Enable the proof-of-work bot challenge on signup and magic-link requests for public portals (KELTA_AUTH_BOT_CHALLENGE_ENABLED=true with one shared HMAC key across pods).
  • Require MFA for internal users (MFA policy).

Tenant IP allowlists#

Without KELTA_SECURITY_TRUSTED_PROXIES, the IP allowlist (Authentication) checks only the socket peer address. That is correct when clients connect to the gateway directly, but behind an ingress every request appears to come from the ingress, so a restricted tenant’s non-admin users are refused. A deployment behind an ingress or load balancer must set KELTA_SECURITY_TRUSTED_PROXIES before tenants use the allowlist; then the allowlist judges the one resolved client address, and forwarded headers from anyone else are ignored.

Secrets#

  • No signing or HMAC secret has a default; generate them once and store them in a secret manager.
  • Rotating KELTA_PUSH_VAPID_* invalidates every browser push subscription; rotating CAMPAIGN_TRACKING_SECRET invalidates links in already-sent email. Plan rotations.
  • KELTA_ENCRYPTION_KEY cannot be rotated without re-encrypting; back it up.

Modules#

KELTA_MODULES_SIGNING_REQUIRED=true. A module runs arbitrary code in the worker and its UI bundle runs in the administrator’s browser session; the signature is the whole trust model. Retire the legacy platform-wide key once per-tenant keys are in place.

Header trust#

The gateway strips client-supplied identity headers before anything reads them, and stamps its own. Deploy the worker, auth, ai and mcp services only behind the gateway or on a private network; a client that can reach the worker directly could supply those headers itself.

Internal endpoints#

/internal/** on every service is protected by KELTA_INTERNAL_TOKEN and must not be routed from the public ingress. Actuator endpoints beyond health should not be public.

Checklist#

  • TLS everywhere; HSTS at the edge
  • CORS_ALLOWED_ORIGIN_PATTERN set to your app origin, not *
  • DIRECT_LOGIN_ENABLED=false
  • KELTA_SECURITY_TRUSTED_PROXIES set to the ingress pod CIDR on the gateway and the auth server
  • KELTA_MODULES_SIGNING_REQUIRED=true
  • Only the gateway, auth and ui are reachable from the internet
  • Secrets in a secret manager; KELTA_ENCRYPTION_KEY backed up
  • Database role without BYPASSRLS
  • Redis and NATS not exposed
  • Flow-webhook URLs treated as secrets; module webhooks verify signatures
  • Alerts on kelta.gateway.auth.failures, authz.denied and ratelimit.exceeded

Edit this page on GitHub