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:
| Endpoint | Method | Auth |
|---|---|---|
/api/cache/invalidate | POST | Bearer CACHE_INVALIDATE_TOKEN, fail-closed |
/api/cache/events | DELETE | same Bearer token |
The token check is:
- Fail-closed — if
CACHE_INVALIDATE_TOKENis unset or empty, nothing is authorized. An unconfigured deployment rejects all invalidations rather than leaving the endpoint open. - Constant-time — comparison uses
timingSafeEqualso 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
NetworkPolicynamed<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-servingandkourier-systemnamespaces (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, plus8112for 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 noNetworkPolicygoverns. 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=trueWithout 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 namespacesdecides — 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
9464on 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) ortruereconciles the policy (default-on);falseskips 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. knext 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.
Read-only root filesystem
Defense in depth against in-container tampering: knext renders your app's container with a read-only root filesystem by default.
- Toggle:
spec.security.readOnlyRootFilesystem(*bool).nil(unset) ortruerendersreadOnlyRootFilesystem: trueon the container (default-on);falsekeeps a writable root, the pre-existing behavior. - No extra volume is mounted by default. Writable scratch volumes cost pod-startup time on
every scale-from-zero wake, so knext only provisions one when your app's image actually needs it.
When
knext deploybuilds the image itself and knows it writes nothing to local disk, it setsspec.security.writeFree: trueon yourNextApp, and the operator then mounts no writable volume at all. That covers:- vinext apps built as a disk-mode binary (the default): image optimization loads its native
library from the image's read-only
native/directory. - Standalone (Node/Bun) apps with object storage (
spec.storage) that use the knext cache handler (everyknext createapp does): the knext adapter turns on Next'simages.customCacheHandleroption, so optimized images are stored through the cache handler instead of.next/cache/imageson disk. On the Bun runtime with Redis configured they are shared by every pod and kept across scale-to-zero. On the Node runtime, and without Redis, they are kept in each pod's memory, capped at 32 MiB by default (setKNEXT_IMAGE_CACHE_MEMORY_BYTESto change it). To keep Next's disk cache instead, build withKNEXT_IMAGE_CACHE_HANDLER=0and setwritableCache: true. - Self-contained standalone executables (
selfContained: trueon the Bun runtime), which carry no native libraries.
- vinext apps built as a disk-mode binary (the default): image optimization loads its native
library from the image's read-only
- Shapes that still get a volume. knext keeps a writable volume when it cannot vouch for the
image:
- A self-contained vinext binary (
selfContained: truewith the vinext target) gets a writable, executable/tmp, because it unpacks a native library there on the first image-optimization request. - A standalone app with object storage whose build still writes images to disk (a custom
cacheHandler, or a Next.js version withoutimages.customCacheHandler) gets Next's cache directory, so the image optimizer can write the variant before knext syncs it to your bucket. - A deploy with
--imageor--skip-buildnever setswriteFree, so these shapes keep the volumes they had before.
- A self-contained vinext binary (
- Standalone apps without object storage never got a volume. The image optimizer re-computes
each variant per request unless the app uses the knext cache handler, and an app with no
cacheHandlerconfigured sees its default ISR/fetch-cache writes rejected (see the callout below). - Opt in to the old, always-mounted behavior with
spec.security.writableCache: true— this mounts both the/tmpscratch volume and (standalone target only) Next's cache directory unconditionally, regardless of your build shape, storage configuration orwriteFree. Use this if you want the image-optimizer's local cache to persist across requests on the standalone target with no object storage configured, or if your app has nocacheHandlerand you want its default on-disk ISR fallback to actually persist rather than fail silently. - What this does not affect: the baked, build-time code cache your runtime warms into the image stays fully readable; a read-only root only removes the (already rare) ability to add a new entry to it at request time, never the ability to read what shipped.
Known gap: an app with no cacheHandler configured also has Next flush revalidated page HTML
back onto a build-output path this cannot make writable without deleting your app's prebuilt pages
— that specific write is rejected under a read-only root regardless of writableCache. It degrades
gracefully (Next logs the failure and keeps serving from its in-memory cache; the page still
returns successfully — ISR just falls back to memory-only instead of persisting across
pods/restarts), but if you rely on Redis-backed ISR, configure a cacheHandler — knext create
and the reference app do this by default — to avoid it entirely.
If your own code writes to a path under the app's working directory at runtime (outside the
paths above), including /tmp (os.tmpdir()), a read-only root filesystem will reject that write.
No app gets a writable /tmp by default except a self-contained vinext binary. Set
spec.security.writableCache: true to mount writable /tmp and .next/cache scratch space, or
set spec.security.readOnlyRootFilesystem: false to opt out entirely.
Digest pinning
knext rejects mutable image tags everywhere.
:latestis rejected. An app whoseimageis:latest, tag-only, or untagged is markedDegraded(reasonInvalidImage) 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 gate — Trivy scans the exact image (as a local OCI layout)
for
HIGH,CRITICALfindings before it is pushed. A failing scan means the image is never published — not "published but flagged." - Signing + attestation on the pushed digest — cosign 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.yamlrelease asset is regenerated with the real pushed digest of the operator image, so what youkubectl applyis exactly what was scanned and signed. See Installing knext.
Runtime hardening
- Distroless, non-root. The operator image is
gcr.io/distroless/static:nonrootrunning as UID65532; the app image runs as non-root UID65532. - No token automount. knext sets
AutomountServiceAccountToken: falseon the app's ServiceAccount. - Graceful shutdown. On
SIGTERMthe server drains in-flight requests and runsafter()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 everyafter()callback, including work that anafter()callback schedules while the drain is already running. The drain has a hard cap (SHUTDOWN_GRACE_MS) kept below the pod'sterminationGracePeriodSeconds; on the vinext targets, work still running at the cap is cut off and the process exits with code 1. On the standalone targets the process still exits with code 0 at the cap, but first logs a warning whose message starts with[knext] shutdown(where the server writes JSON logs, that text is the log line'smsgfield) and says what it cut off: the number of in-flight requests it dropped where the process can count them, or that the number is unknown where it cannot. If that warning shows up in your pod logs, raiseSHUTDOWN_GRACE_MSor find the request orafter()callback that never finishes. Apps created before this behaviour shipped carry an olderknext-bun-entry.mjsthat exits without waiting forafter()work. Copyknext-bun-entry.mjsandruntime-contract.mjsfrom 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.secrets — envFrom / envMap) and the app
reads them from the environment.
Operator permissions
The operator runs with a least-privilege ClusterRole (config/rbac/role.yaml in the install
bundle) and, in particular, no permission on core Secrets — no get, list, watch, or any
write verb, cluster-wide or in any namespace. Your spec.secrets entries are passed to the
kubelet as references (envFrom.secretRef, valueFrom.secretKeyRef), and the kubelet reads the
Secret when your pod starts. You can verify what an installed operator may do:
kubectl auth can-i list secrets --all-namespaces \
--as=system:serviceaccount:kn-next-operator-system:kn-next-operator-controller-manager
# noWhat the operator does still hold, so you can size the risk accurately: create/update/delete on
the Knative Services, ServiceAccounts, NetworkPolicies, image-prewarm DaemonSets, and Kafka
sources it manages, and read-only access to PersistentVolumeClaims and Knative Revisions.
Anyone allowed to create a NextApp can reference a Secret in their own namespace by name, so
restrict who may create NextApp resources with ordinary namespace RBAC.
This removes the operator's direct access to Secrets, not every path to them: because it can create workloads cluster-wide, a compromised operator could still reach a Secret through a pod it creates. Install the operator per namespace, or add an admission policy, to close that path.
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.