porthole drops an ephemeral container next to any running pod, attaches to it from a slick web terminal, and logs every decision. Identity verified at the edge by your OIDC gateway. Every namespace touch decided by OPA. kubectl exec for developers who don't need kubectl.
$ ss -tlnp LISTEN 0 128 *:8080 *:* LISTEN 0 128 *:9090 *:* $ redis-cli -h cache PING PONG $ stty size 38 132 $ _▌
Why
Lens and k9s are great if every developer has cluster credentials and a kubeconfig. Most don't. porthole hands them a URL, an OIDC login, and a terminal — backed by ephemeral containers so the target pod stays exactly as deployed.
netshoot, get a shell.Install
helm install away.
The chart at oci://ghcr.io/bcollard/charts/porthole ships the Deployment, ServiceAccount + cluster-wide RBAC, and an OPA sidecar pre-loaded with a sensible default policy. Bring your own OIDC-aware front (Envoy Gateway, ingress-nginx + oauth2-proxy, Traefik, …) — porthole only cares that X-ID-Token or Authorization: Bearer … contains a JWT it can verify against your IdP's JWKS.
$ helm install porthole oci://ghcr.io/bcollard/charts/porthole \
--namespace porthole --create-namespace \
-f my-values.yaml
my-values.yaml — two shapes to start fromSmallest possible setup — runs in kind or any cluster you can kubectl port-forward into. No JWT validation, no OPA sidecar: porthole stamps every request as the local-dev principal and short-circuits authZ.
# Skip JWT validation entirely. porthole stamps every request
# as the `local-dev` principal — fine over kubectl port-forward,
# never production. The default OPA data binds `local-dev` →
# admin cluster-wide, so the UI lights up immediately.
auth:
disabled: true
# Optional — turn the OPA sidecar off too to keep the pod
# single-container. porthole short-circuits authZ to allow.
opa:
enabled: false
$ kubectl -n porthole port-forward svc/porthole 8081:8081 → http://localhost:8081/ui/
The production shape — JWT validation at porthole, Gateway-API exposure, OPA sidecar. The chart serialises whatever you put under opa.policy / opa.data into a ConfigMap it owns and mounts on the OPA sidecar — no separate ConfigMap to author. The rules below are placeholders; real deployments keep the Rego in files and pull them in via --set-file (footer below) so diffs stay reviewable. OPA hot-reloads on every chart update — no pod restart.
# JWT validation against your IdP's JWKS. The OIDC handshake
# happens at the gateway; porthole just verifies signatures
# and extracts `sub`, `email`, `groups`.
auth:
disabled: false
jwksURL: "https://idp.example.com/realms/eng/protocol/openid-connect/certs"
issuer: "https://idp.example.com/realms/eng"
audience: "porthole"
# CSWSH defence on the /term WebSocket upgrade. Set this
# whenever you're behind a cookie-authenticated gateway.
wsAllowedOrigins: "https://porthole.example.com"
# Gateway API HTTPRoute. Attaches to whichever Gateway your
# platform team operates — Envoy Gateway, Istio, Cilium, …
gatewayAPI:
enabled: true
hostnames: ["porthole.example.com"]
parentRefs:
- name: platform-gateway
namespace: gateway-system
# OPA sidecar — runs the bundled group×role×namespace policy
# by default. Override `policy` / `data` for your own bindings;
# keep them in real files and use --set-file (see footer).
opa:
enabled: true
policy: |
package porthole.authz
# ...your bindings, roles, namespace-label predicates here
data: |
{
"policy": {
"roles": { /* action → permissions */ },
"bindings": [ /* group → role × ns scope */ ]
}
}
# Optional — sweep porthole-injected ephemeral containers
# older than this TTL. Pairs well with short debug sessions.
ecSweepTTL: "30m"
$ helm upgrade porthole oci://ghcr.io/bcollard/charts/porthole --reuse-values --set-file opa.policy=./policy/porthole.rego --set-file opa.data=./policy/data.json
Identity & authorization
porthole separates who from what's allowed. Your gateway proves who. OPA decides what. Both arrows are independent so you can swap either side without touching the other.
The gateway runs the OIDC handshake (Keycloak, Auth0, Google, …) and forwards the validated request upstream with the ID token in a header. porthole reads X-ID-Token first, falls back to Authorization: Bearer …, validates against your IdP's JWKS, and extracts sub, email, groups.
# SecurityPolicy on Envoy Gateway
spec:
oidc:
provider:
issuer: "https://idp.example.com/realms/eng"
clientID: "porthole"
clientSecret:
name: porthole-oidc-secret
scopes: [openid, profile, email]
forwardAccessToken: true
Every API call porthole serves is gated by one of six hardcoded actions (defined in pkg/auth/opa.go). The action, the user (from the JWT), and the live namespace labels go to OPA; OPA returns {allow, reason} and the reason flows straight into the audit log. "Roles" in the policy data are just your own human-friendly bundles of these actions — the action names themselves are not extensible from the policy.
| Action | What it gates |
|---|---|
list_namespaces | Populating the namespace sidebar. Cluster-scoped — only an unconditional namespace_glob: "*" binding (no label constraints) can grant it. |
list_pods | Listing pods, services, and live namespace labels for a namespace. |
list_ec | Listing porthole-injected ephemeral containers already running on a pod. |
inject_ec | Patching the pod's ephemeralcontainers subresource to start a new debug container. |
attach_ec | Opening the WebSocket PTY stream to an ephemeral container. |
terminate_ec | Killing a porthole-injected ephemeral container. |
Skip both layers. Every request becomes the local-dev principal; OPA isn't consulted. Zero JWT validation, zero policy evaluation — useful for tearing things apart over kubectl port-forward.
auth:
disabled: true # JWT validation off — `local-dev` for everyone
opa:
enabled: false # authZ short-circuits to allow
Coarse but simple — the JWT's groups claim maps directly to a role, no namespace constraint. Good when porthole serves a single team and namespaces aren't multi-tenant.
opa:
data: |
{
"policy": {
"roles": {
"viewer": ["list_namespaces", "list_pods", "list_ec"],
"debugger": ["list_namespaces", "list_pods", "list_ec",
"inject_ec", "attach_ec", "terminate_ec"]
},
"bindings": [
// Anyone in this group can debug any namespace.
{"group": "platform-oncall", "role": "debugger", "namespace_glob": "*"},
// Read-only — see pods, can't inject.
{"group": "support", "role": "viewer", "namespace_glob": "*"}
]
}
}
The full pattern. A binding can combine JWT groups, namespace name globs, namespace labels, and an optional business_hours: true time window. All present constraints must hold (logical AND), so you can express things like "tenant-acme debugs tenant-* namespaces labeled tenant=acme", "secops debugs anything labeled tier=production", or "junior devs only during working hours".
How the time window resolves: porthole stamps input.now on every OPA call with its own time.Now().UTC().Format(RFC3339) — the wall clock is the porthole pod's, not the user's browser. The Rego in policy/porthole.rego compares via time.weekday and time.clock: Mon–Fri, 09 ≤ hour < 17 UTC. Want a different range or a non-UTC zone? Edit the violates_time_window rule (e.g. time.clock(now, "America/New_York") for ET). The chart's bundled default policy doesn't include business_hours — load the full file via --set-file opa.policy=./policy/porthole.rego to enable it.
opa:
data: |
{
"policy": {
"roles": {
"debugger": ["list_namespaces", "list_pods", "list_ec",
"inject_ec", "attach_ec", "terminate_ec"]
},
"bindings": [
// Per-team — glob match on namespace name only.
{"group": "team-payments",
"role": "debugger",
"namespace_glob": "payments-*"},
// Tenant — glob AND label must both match.
{"group": "tenant-acme",
"role": "debugger",
"namespace_glob": "tenant-*",
"namespace_labels": {"tenant": "acme"}},
// SecOps — any namespace labeled tier=production.
{"group": "secops",
"role": "debugger",
"namespace_labels": {"tier": "production"}},
// Junior devs — dev-* only, Mon–Fri 09:00–17:00 (server-side).
// Needs policy/porthole.rego loaded via --set-file (the default
// chart policy ignores `business_hours`).
{"group": "junior-devs",
"role": "debugger",
"namespace_glob": "dev-*",
"business_hours": true}
]
}
}
# What porthole sends OPA per inject_ec call:
{
"user": {"groups": ["team-payments"]},
"request": {"action": "inject_ec",
"namespace": "payments-checkout"},
"namespace_labels": {"tier": "staging", "team": "payments"}
}
opa eval against the same file gives the platform team a regression suite that runs anywhere.
Audit
Every inject, attach-deny, and cleanup emits a structured slog line on stdout shaped to the Elastic Common Schema (v8.11). Picks up cleanly in Elastic, Loki, Splunk — anything that keys on event.action, event.outcome, user.id, or kubernetes.namespace, with no custom parser.
{
"@timestamp": "2026-06-07T10:53:45Z",
"log.level": "WARN",
"message": "inject",
"ecs.version": "8.11",
"event": {
"kind": "event",
"category": ["iam"],
"action": "inject_ec",
"type": "denied",
"outcome": "failure",
"dataset": "porthole.audit",
"provider": "porthole",
"duration": 12387251,
"reason": "matched: group=junior-devs role=debugger ns=dev-checkout — outside business hours"
},
"user": { "id": "alice@example.com" },
"source": { "ip": "10.0.1.5" },
"kubernetes": {
"namespace": "dev-checkout",
"pod": { "name": "api-7d4b9c5f-mznpr" }
},
"container": {
"image": { "name": "nicolaka/netshoot" }
}
}
Compare
Porthole is a narrow tool: a browser-based attach-to-pod terminal that injects ephemeral containers, validates JWTs from an upstream OIDC gateway, and asks OPA for authorization. Here's how it lines up against three projects that overlap in different ways.
| Porthole | Teleport | Headlamp | Lens / OpenLens | |
|---|---|---|---|---|
| Form factor | Web (in-cluster) | Web + CLI (tsh) |
Web (in-cluster or desktop) | Desktop (Electron) |
| Primary purpose | Attach-to-pod terminal | Access platform (SSH, k8s, DB, RDP, apps) | General-purpose k8s dashboard | General-purpose k8s IDE |
| Exec mechanism | Ephemeral container (any image) | kubectl exec into existing container |
kubectl exec into existing container |
kubectl exec into existing container |
| Custom debug image | Yes — per-session, any registry | No (uses container's own shell) | No | No |
| Authentication | JWT from upstream OIDC gateway (BYO IdP) | Built-in IdP + SSO connectors (SAML / OIDC / GitHub) | OIDC (built-in) or kubeconfig | Local kubeconfig only |
| Authorization | OPA sidecar (Rego + bindings) | Built-in RBAC (Teleport roles) | Kubernetes RBAC (impersonation) | Kubernetes RBAC (user's kubeconfig) |
| Multi-cluster | No (one cluster per install) | Yes (cluster of clusters) | Yes | Yes |
| Audit log | Structured slog JSON per inject / attach / cleanup | Yes, with session recording / replay | No native audit (k8s audit log applies) | No (runs on user's laptop) |
| Footprint | One Deployment + OPA sidecar | Multi-component (auth, proxy, agents, backend store) | One Deployment | Desktop install per user |
| License | Apache-2.0 | Apache-2.0 (Community) + paid Enterprise | Apache-2.0 (CNCF Sandbox) | Lens: Mirantis EULA. OpenLens: MIT |
In one line: pick Porthole when you already have an OIDC gateway, you want one central audit trail of pod attaches, and you regularly need to debug pods that don't ship with their own shell. If any one of those three is false, one of the projects above is probably a better fit.
Under the hood
Stdout, stdin, and terminal resize each travel on the single browser ↔ porthole websocket — two as binary frames, one as a JSON text frame. The k8s remotecommand executor stitches the upstream half. The diagram below traces a byte from xterm.js all the way to the kubelet PTY.
FAQ
kubectl debug or Lens?kubectl debug requires every developer to have a working kubeconfig and the right RBAC. Lens is great but assumes the same. porthole removes the kubeconfig from the developer's laptop entirely — the cluster sees a single SA (porthole's), and a corporate OIDC identity gets translated into namespace-scoped permission via OPA. The footprint on developers is just a URL.
They give you kubectl exec ergonomics without modifying the workload container. Different image (your forensics toolkit, psql, …), same network namespace, same volumes as the target. The pod's deployment YAML stays untouched.
Because policies grow. The Rego file in the repo demonstrates: groups from the JWT, namespace label match, glob namespace matching, business-hours constraints — combined in arbitrary ways without recompiling porthole. Sidecar gives you hot reload on a ConfigMap update.
No. All state lives in Kubernetes (the ephemeral container, the kube events) or in the JWT (identity). porthole itself is stateless — you can run multiple replicas behind the gateway.
One slog JSON line per inject and per attach-deny: user, source_ip, namespace, pod, image, outcome, reason, duration_ms. Successful attaches show up in gin's access log; the byte stream is never logged.
Yes. Anything that does OIDC and forwards a JWT in a header upstream works — Kong, Traefik EE, Apache APISIX, Cloudflare Access, an oauth2-proxy in front. porthole only cares that X-ID-Token or Authorization: Bearer … contains a JWT that validates against the JWKS URL you set.
The websocket is the only channel. Stdin and stdout travel as binary frames; the browser sends a JSON text frame for resize: {"type":"resize","cols":120,"rows":30}. porthole pushes that into a remotecommand.TerminalSizeQueue; the kubelet turns it into TIOCSWINSZ on the PTY.
Yes. Source on GitHub. Issues and PRs welcome.