knext

Security

knext's security invariants — authed mutating endpoints, network isolation, digest pinning, supply-chain signing, and runtime hardening.

Security in knext is not a milestone to defer — these controls run through every phase. The non-negotiable rule: no unauthenticated mutating endpoints, ever. Several controls are enforced automatically; the rest are documented guarantees knext holds to.

No unauthenticated mutating endpoints

Any route that changes state requires auth. Two app routes mutate cache state, and both require a Bearer token:

EndpointMethodAuth
/api/cache/invalidatePOSTBearer CACHE_INVALIDATE_TOKEN, fail-closed
/api/cache/eventsDELETEsame Bearer token

The token check is:

  • Fail-closed — if CACHE_INVALIDATE_TOKEN is unset or empty, nothing is authorized. An unconfigured deployment rejects all invalidations rather than leaving the endpoint open.
  • Constant-time — comparison uses timingSafeEqual so the token cannot be recovered via response timing (length is checked first, which does not leak the secret).

The token is provisioned from a Kubernetes Secret into the CACHE_INVALIDATE_TOKEN env var — never hardcoded.

There is intentionally no GET /api/cache/invalidate handler. A mutating GET is prefetchable/link-triggerable and would leak the Bearer token into URLs and logs. App Router returns 405 for the unexported GET; POST is the only invalidation entrypoint.

The browser RUM beacon POST /api/rum is a deliberate, justified exception — a bounded metrics aggregator, not a write primitive. See Observability → RUM for the full rationale.

Network isolation

Auth is one factor; network isolation is the second. knext reconciles a default-on Kubernetes NetworkPolicy for your app automatically.

  • Object: a NetworkPolicy named <app>-allow-ingress, tied to your app's lifecycle (garbage-collected on delete).

  • Targets: the app's serving pods (selected by serving.knative.dev/service: <app>).

  • Allows ingress from: the knative-serving and kourier-system namespaces (so scale-from-zero traffic keeps flowing) on the serving and metrics ports, plus same-namespace pods on the metrics ports only. Everything else — arbitrary cross-namespace and external pod-direct traffic — is denied.

  • Ports are restricted, and your app's own port is not among them. The policy admits the Knative queue-proxy ports (8012/8013, plus 8112 for internal TLS), the queue-proxy metrics port (9090) and knext's app-metrics port (9464). Your app's container port is deliberately excluded: the queue-proxy reaches it over pod-local loopback, which no NetworkPolicy governs. The effect is that a pod sharing your namespace can no longer dial your app container directly and bypass the queue-proxy — and with it your concurrency limit.

  • Scraping from another namespace requires a label on that namespace. If your Prometheus runs outside your app's namespace, label its namespace and it may scrape the app metrics port (9464) — and only that:

    kubectl label namespace monitoring knext.dev/metrics-scrape=true

    Without the label, cross-namespace scraping is denied — including for knext's own shipped PodMonitor. A labelled namespace never gains your app's serving ports.

Know what this label is and is not. It is a cluster-level grant, not per-app consent:

  • The label lives on the Namespace object, so whoever can update namespaces decides — normally a cluster admin, but on a platform with self-service namespace creation a tenant can create and label its own.
  • Once a namespace is labelled, every pod in it — not only Prometheus — can scrape 9464 on every knext app in the cluster. The policy matches the namespace, not a specific workload, because knext cannot know how your Prometheus pods are labelled.
  • An individual app owner has no per-app opt-out short of spec.security.networkPolicy: false.

If you need tighter identity than "any pod in a trusted namespace", adding your own NetworkPolicy will not help: Kubernetes policies are additive, so a second policy unions its allow-rules with knext's rather than narrowing them. The only way to tighten this is to turn knext's policy off (spec.security.networkPolicy: false) and write your own that expresses exactly the access you want.

Labelling no namespace keeps the previous behaviour exactly.

Removing the label denies new scrapes, but a connection already established may survive until it is closed — Kubernetes CNIs evaluate policy when a connection is opened, not on every packet.

  • Toggle: spec.security.networkPolicy (*bool). nil (unset) or true reconciles the policy (default-on); false skips it and deletes any existing one on the next reconcile.

If a pod in your namespace calls your app directly on its container port, that call will stop working. This is the point of the restriction, not a side effect — but if you rely on it, either call the app through its service URL (routed via the gateway, which is allowed) or set spec.security.networkPolicy: false to opt out entirely.

Honest scope: L3/L4, not L7. A NetworkPolicy filters by source pod/namespace at the network layer — it cannot target an HTTP path. It does not isolate /api/cache/invalidate per se; it makes the whole pod unreachable for disallowed direct traffic (a leaked token is useless to an attacker who cannot route to the pod). It is also only enforced if your cluster CNI supports NetworkPolicy (Calico, Cilium, etc.) — on a non-enforcing CNI the policy is a no-op.

knext tells you which case you are in instead of leaving it silent. The operator reports a NetworkPolicyEnforced condition on every NextApp: True when a NetworkPolicy-enforcing agent (Calico, Cilium, kube-router, Weave Net, Antrea, Canal) is detected on the cluster, False when flannel is the CNI — there the policy is declarative only: it is written but enforces nothing — and Unknown when the CNI cannot be identified. Treat Unknown as unenforced until you verify it; a detector that guessed "enforced" would be worse than none. kn-next doctor runs the same read-only detection as a preflight check. Note that flannel is what OrbStack and stock OKE clusters run — on those, treat network isolation as absent unless you install a policy-capable CNI.

Digest pinning

knext rejects mutable image tags everywhere.

  • :latest is rejected. An app whose image is :latest, tag-only, or untagged is marked Degraded (reason InvalidImage) and never deployed — a ref is accepted only if it is digest-pinned (@sha256:).
  • knext's own images are digest-pinned too. A build-time check fails if any internal deployment manifest references a non-pinned tag, so knext never ships a mutable image reference.

See Operator → Security defaults.

Supply chain

Both the app image and the operator image go through the same supply-chain pipeline, with a strict ordering: build → SBOM → scan gate → push → sign. Nothing reaches the registry before it passes the scan, and signing happens on the digest that was actually pushed.

  • SBOM per image — generated with syft (SPDX-JSON), published as an artifact and attested.
  • Vulnerability scan as a push gateTrivy scans the exact image (as a local OCI layout) for HIGH,CRITICAL findings before it is pushed. A failing scan means the image is never published — not "published but flagged."
  • Signing + attestation on the pushed digestcosign keyless signing (OIDC / Sigstore) of the digest that was pushed, plus an SBOM attestation (cosign attest --type spdxjson) bound to that digest.
  • SLSA provenance — the build carries a BuildKit SLSA provenance attestation, and the pipeline asserts it survived the push before anything is signed.
  • Digest-pinned release bundle — the operator's install.yaml release asset is regenerated with the real pushed digest of the operator image, so what you kubectl apply is exactly what was scanned and signed. See Installing knext.

Runtime hardening

  • Distroless, non-root. The operator image is gcr.io/distroless/static:nonroot running as UID 65532; the app image runs as a non-root node user.
  • No token automount. knext sets AutomountServiceAccountToken: false on the app's ServiceAccount.
  • Graceful shutdown. On SIGTERM the server drains in-flight requests and runs after() callbacks before exit, then closes the metrics server — no dropped requests on scale-down. On the standalone targets the supervisor forwards the signal to Next.js; on the vinext targets (the Bun executable and the Node server) the app's server entry does the drain itself. It waits for every after() callback, including work that an after() callback schedules while the drain is already running. The drain has a hard cap (SHUTDOWN_GRACE_MS) kept below the pod's terminationGracePeriodSeconds; on the vinext targets, work still running at the cap is cut off and the process exits with code 1. Apps created before this behaviour shipped carry an older knext-bun-entry.mjs that exits without waiting for after() work. Copy knext-bun-entry.mjs and runtime-contract.mjs from a freshly created app into yours to pick it up.

Secrets

Secrets live in Kubernetes Secrets / env only — never in config files, source, container images, or URLs. knext provisions them (via spec.secretsenvFrom / envMap) and the app reads them from the environment.

Honest scope — not yet shipped. Service-to-service mTLS/authz between the gateway and backends, and the cluster-local gRPC backend services with no public ingress, are designed but not yet shipped. Today's enforced boundary is endpoint auth plus the network policy above.

See also: Operator & the NextApp CRD · Observability · Scale-to-zero.

On this page