Skip to main content

Deny SVID Issuance with a Server Webhook

Attestation collects evidence about a process and is useful for determining that process's identity. The collected evidence does not answer whether that workload should receive an SVID.

This example builds a webhook that the Trust Domain Server calls during issuance and that refuses an identity to workloads failing a business rule you define, written in the language of your choice.

This guide refuses an identity using the Server Workload Attestation Extension, a webhook the Trust Domain Server calls during issuance. See Which extension do you need? for other examples of extending Defakto behavior.

What you'll build

An HTTP webhook, running alongside your Trust Domain Server, that decides on every SVID request using the workload's Kubernetes namespace:

NamespaceVerdictResponse body
starts with deniedDeny{"error": "namespace \"denied\" is not permitted to receive SVIDs"}
anything elseAllow{}

Namespace is a stand-in for whatever your real rule is: a CMDB lookup, an inventory check, a compliance flag. The mechanics of deciding and responding are identical.

The webhook also logs every attribute it receives, so you can see what evidence was collected about the workload.

warning

This walkthrough uses a single pod and plain in-cluster HTTP, so the mechanics of the extension stay in focus. Before running an extension in production, see Availability and reliability for replica and timeout guidance, and Security for TLS and webhook authentication.

Prerequisites

  • A trust domain with Trust Domain Servers running on Kubernetes, installed with the spirl-server Helm chart
  • A Kubernetes cluster registered with Defakto, running the Agent and connected to the Trust Domain Servers above
  • kubectl, helm, and spirlctl, with a kubectl context for the cluster running your Trust Domain Servers
  • Your trust domain deployment ID, which is both the Helm release name and the namespace. See Deploy Trust Domain Servers

Two terminals are useful. The webhook runs in the foreground in one, leaving the other free for the remaining commands.

Create the namespace and Service

The webhook must be reachable from the Trust Domain Server pods. This example installs it in the same Kubernetes cluster as the Trust Domain Server.

kubectl create namespace defakto-extension
cat > extension-webhook-service.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
name: extension-webhook
namespace: defakto-extension
spec:
selector:
app: extension-webhook
ports:
- port: 8080
targetPort: 8080
protocol: TCP
name: http
EOF
kubectl apply -f extension-webhook-service.yaml

The Service resolves to http://extension-webhook.defakto-extension.svc.cluster.local:8080 inside the cluster, which is the address the server will call.

Write and run the webhook

The webhook accepts POST /attest and answers with a flat JSON object. Two rules govern the response, and getting either wrong is the usual cause of a broken extension:

  • Every verdict is HTTP 200, including a denial. The decision travels in the body as an error key, not in the status code.
  • Always write a body. An empty body is not valid JSON, and the parse failure denies issuance rather than approving it. Return {} when the extension has nothing to add.
  1. Start a pod using the Go image, labelled so the Service selects it:

    extension-webhook-pod.yaml
    apiVersion: v1
    kind: Pod
    metadata:
    name: extension-webhook
    namespace: defakto-extension
    labels:
    app: extension-webhook
    spec:
    containers:
    - name: webhook
    image: golang:1.26
    command: ["sleep", "infinity"]
    kubectl apply -f extension-webhook-pod.yaml
    kubectl -n defakto-extension wait --for=condition=Ready pod/extension-webhook
  2. Exec into the pod and set up the module:

    kubectl -n defakto-extension exec -it extension-webhook -- bash

    Inside the pod:

    mkdir -p /webhook && cd /webhook
    go mod init extension-webhook
  3. Create the webhook. It uses only the standard library, so there is nothing to download:

    cat > main.go << 'EOF'
    package main

    import (
    "encoding/json"
    "fmt"
    "log"
    "maps"
    "net/http"
    "os"
    "slices"
    "strings"
    )

    func main() {
    port := os.Getenv("PORT")
    if port == "" {
    port = "8080"
    }

    http.HandleFunc("POST /attest", handleAttest)
    http.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
    w.WriteHeader(http.StatusOK)
    })

    log.Printf("listening on :%s", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
    }

    func handleAttest(w http.ResponseWriter, r *http.Request) {
    var req map[string]any
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    // The webhook could not reach a verdict at all, so this is a 4xx
    // rather than a denial.
    http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
    return
    }

    // Log every attribute so you can see what is available to decide on.
    log.Printf("received request with attributes:")
    attrs := flatten("", req)
    for _, k := range slices.Sorted(maps.Keys(attrs)) {
    log.Printf(" %s=%s", k, attrs[k])
    }

    ns := attrs["kubernetes.pod.namespace"]

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)

    if strings.HasPrefix(ns, "denied") {
    log.Printf("DENY ns=%s", ns)
    writeJSON(w, map[string]string{
    "error": fmt.Sprintf("namespace %q is not permitted to receive SVIDs", ns),
    })
    return
    }

    // Allowing a workload means returning an empty object. To attach
    // attributes to allowed workloads as well, see the Add Custom
    // Attributes guide.
    log.Printf("ALLOW ns=%s", ns)
    writeJSON(w, map[string]string{})
    }

    // writeJSON always emits an object. An empty body fails to parse on the
    // server and denies issuance.
    func writeJSON(w http.ResponseWriter, body map[string]string) {
    if err := json.NewEncoder(w).Encode(body); err != nil {
    log.Printf("failed to write response: %v", err)
    }
    }

    // flatten turns the nested request into dotted keys such as
    // kubernetes.pod.namespace.
    func flatten(prefix string, m map[string]any) map[string]string {
    out := make(map[string]string)
    for k, v := range m {
    key := k
    if prefix != "" {
    key = prefix + "." + k
    }
    switch val := v.(type) {
    case map[string]any:
    maps.Copy(out, flatten(key, val))
    case string:
    out[key] = val
    }
    }
    return out
    }
    EOF
  4. Run it in the foreground:

    go run main.go
    listening on :8080
note

The webhook is a child of your kubectl exec session, so it stops when that session ends. Leave this terminal running and use a second one for the remaining steps. The live log is the clearest view of each verdict as it happens.

Wire it into Defakto

In your second terminal, point the Trust Domain Server at the Service. The Helm release name and the namespace both equal your trust domain deployment ID.

Find it with spirlctl trust-domain deployment list. It is the tdd- prefixed value in the ID column:

spirlctl trust-domain deployment list
Name ID Configuration State Last Configured
us-west-2 tdd-b80yo4kabl Up to date 2026-08-12 13:55:11.823 +0000 UTC
us-east-1 tdd-e8m6d8tx1u Up to date 2026-08-12 13:55:20.558 +0000 UTC

2 trust domain deployments found.
export DEPLOYMENT_ID=<your-trust-domain-deployment-id>

helm upgrade --install "$DEPLOYMENT_ID" \
oci://ghcr.io/spirl/charts/spirl-server \
--namespace "$DEPLOYMENT_ID" \
--reuse-values \
--set trustDomainDeployment.deployment.extensionWorkloadAttestation.webhookUrl="http://extension-webhook.defakto-extension.svc.cluster.local:8080/attest" \
--set trustDomainDeployment.deployment.extensionWorkloadAttestation.timeout="10s"
kubectl -n "$DEPLOYMENT_ID" rollout status deployment -l app.kubernetes.io/name=spirl-server

--reuse-values preserves the rest of your server configuration. This extension is Helm-values configuration rather than Managed Configuration, so it takes effect as the server pods roll, not live.

The webhook is now called for every workload in every cluster in this trust domain. To narrow that, add invokeForClusterIds or invokeForWorkloadAttributes. See Attribute filtering.

Verify

In your agent cluster, create one namespace that should be allowed and one that should be denied:

kubectl create namespace allowed
kubectl create namespace denied

The allowed workload receives an SVID

kubectl run svid-check -n allowed -l "k8s.spirl.com/spiffe-csi=enabled" --restart=Never --image ghcr.io/spirl/spirldbg:latest --rm -i -- spirldbg svid-jwt --audience extension-check

The pod succeeds and prints its SPIFFE ID. In the terminal running the webhook you should see the attributes it received, followed by the verdict:

received request with attributes:
cluster.cluster_id=c-xxxxxxxxxx
kubernetes.pod.name=svid-checker
kubernetes.pod.namespace=allowed
kubernetes.pod.service_account=default
...
ALLOW ns=allowed

The empty {} response is what allows issuance to proceed exactly as it would without the extension.

The denied workload receives nothing

Run the same check in the denied namespace:

kubectl run svid-check -n denied -l "k8s.spirl.com/spiffe-csi=enabled" --restart=Never --image ghcr.io/spirl/spirldbg:latest --rm -i -- spirldbg svid-jwt --audience extension-check

The webhook log shows the denial:

received request with attributes:
kubernetes.pod.namespace=denied
...
DENY ns=denied

Clean up

Remove the extension from the server configuration:

helm upgrade "$DEPLOYMENT_ID" \
oci://ghcr.io/spirl/charts/spirl-server \
--namespace "$DEPLOYMENT_ID" \
--reuse-values \
--set trustDomainDeployment.deployment.extensionWorkloadAttestation.webhookUrl=""
kubectl -n "$DEPLOYMENT_ID" rollout status deployment -l app.kubernetes.io/name=spirl-server

Then delete the test workloads and the webhook:

On the cluster with the agents:

kubectl delete namespace allowed denied

On the cluster with the servers:

kubectl delete namespace defakto-extension

Next steps