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#
| Path | Service | Protection |
|---|---|---|
/actuator/health/* | all | per-IP limit |
/api/webhooks/{flowId} | gateway → worker | the 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 → worker | the module verifies the signature; per-IP limit |
/api/webhooks/mail/** | gateway → worker | inbound mail (optional feature); per-IP limit |
/api/webhooks/ses | gateway → worker | Amazon SNS signature on bounce and complaint notifications |
/api/track/** | gateway → worker | campaign open, click and unsubscribe links; the HMAC tracking token |
/api/telehealth/visits/**, /api/telehealth/webhooks/** | gateway → worker | visit links: their signed token; LiveKit webhooks: the signed JWT and body digest (optional feature) |
/scim/** | gateway → worker | the worker checks the SCIM bearer token (SCIM) |
/otel/** | gateway → collector | browser telemetry export |
/portal/** | auth | magic-link and signup pages; per-IP limits in the auth server; optional bot challenge |
/oauth2/**, /login/**, /saml2/** | auth | standard 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/webhookscovers 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_SHAREcaps one member. Tighten the share for tenants with many members. RATE_LIMIT_EXEMPT_CIDRSbypasses 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, tenantdefault) and change the first-boot password before the deployment takes traffic. kelta-auth replaces the database’s seeded password on first boot — withKELTA_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_COUNTmust equal the number of proxies in front of the auth server (0if none); otherwise a client can forgeX-Forwarded-Forand 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=truewith 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; rotatingCAMPAIGN_TRACKING_SECRETinvalidates links in already-sent email. Plan rotations. KELTA_ENCRYPTION_KEYcannot 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_PATTERNset to your app origin, not* -
DIRECT_LOGIN_ENABLED=false -
KELTA_SECURITY_TRUSTED_PROXIESset 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_KEYbacked 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.deniedandratelimit.exceeded