Skip to main content

Registration Entries with a Server Webhook

Defakto derives a workload's SPIFFE ID from a path template, so every workload in a cluster shares one naming scheme. Teams migrating from SPIRE often want to re-use existing logic to allow an explicit list of registered workloads with a hand-assigned path.

This example reproduces that behavior with a server extension.

This guide controls SPIFFE ID naming 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

A webhook holding a list of registration entries. Each entry pairs a set of attribute selectors with a SPIFFE ID path. On every SVID request the webhook finds the first entry whose selectors all match, and returns its path as a custom attribute. The cluster's path template is set to that attribute, so the entry decides the identity.

A workload matching no entry is denied. That makes registration fail-closed: Being absent from the list is a rejection, not a fallback.

warning

Setting a cluster's path template to /{{!custom.path}} means every workload in that cluster needs a matching entry. Existing workloads stop receiving SVIDs the moment the template changes. Use a test cluster for this walkthrough, or scope the template with per-workload overrides.

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 test 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, and the test cluster's ID. 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

Everything here goes in the cluster running your Trust Domain Servers, since the webhook must be reachable from the server pods.

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

Write and run the webhook

  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. Write the registration list. Each entry pairs a set of selectors with the SPIFFE ID path to assign when every selector matches:

    cat > entries.json << 'EOF'
    [
    {
    "path": "payments/gateway",
    "selectors": {
    "kubernetes.pod.namespace": "payments",
    "kubernetes.pod.service_account": "gateway"
    }
    },
    {
    "path": "payments/checkout-api",
    "selectors": {
    "kubernetes.pod.namespace": "payments",
    "kubernetes.pod.service_account": "checkout-api"
    }
    }
    ]
    EOF

    Selectors are not limited to the two used here. A webhook can match on any attribute the server sends it, including ones a path template cannot use, such as kubernetes.pod.container.<container-name>.image.name for pinning an entry to a specific image. Read the webhook's attribute log to see the exact keys your pods produce.

  4. Create the webhook. It uses only the standard library, so there is nothing to download:

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

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

    // Entry pairs attribute selectors with the SPIFFE ID path to assign when
    // every selector matches.
    type Entry struct {
    Path string `json:"path"`
    Selectors map[string]string `json:"selectors"`
    }

    var entries []Entry

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

    path := os.Getenv("ENTRIES_PATH")
    if path == "" {
    path = "entries.json"
    }
    loadEntries(path)

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

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

    // loadEntries refuses to start on a missing or empty list, rather than
    // starting up and denying every workload.
    func loadEntries(path string) {
    data, err := os.ReadFile(path)
    if err != nil {
    log.Fatalf("failed to read %s: %v", path, err)
    }
    if err := json.Unmarshal(data, &entries); err != nil {
    log.Fatalf("failed to parse %s: %v", path, err)
    }
    if len(entries) == 0 {
    log.Fatalf("%s contains no entries", path)
    }
    for i, e := range entries {
    if e.Path == "" || len(e.Selectors) == 0 {
    log.Fatalf("entry %d needs both a path and at least one selector", i)
    }
    }
    }

    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
    }

    attrs := flatten("", req)
    log.Printf("received request with attributes:")
    for _, k := range slices.Sorted(maps.Keys(attrs)) {
    log.Printf(" %s=%s", k, attrs[k])
    }

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

    matched := findEntry(attrs)
    if matched == nil {
    log.Printf("DENY ns=%s pod=%s: no matching entry",
    attrs["kubernetes.pod.namespace"], attrs["kubernetes.pod.name"])
    writeJSON(w, map[string]string{
    "error": "no registration entry matches this workload",
    })
    return
    }

    log.Printf("ALLOW ns=%s pod=%s path=%s",
    attrs["kubernetes.pod.namespace"], attrs["kubernetes.pod.name"], matched.Path)
    writeJSON(w, map[string]string{"path": matched.Path})
    }

    // findEntry returns the first entry whose every selector matches.
    func findEntry(attrs map[string]string) *Entry {
    for i := range entries {
    if matches(entries[i], attrs) {
    return &entries[i]
    }
    }
    return nil
    }

    func matches(e Entry, attrs map[string]string) bool {
    for k, v := range e.Selectors {
    if attrs[k] != v {
    return false
    }
    }
    return true
    }

    // 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, matching the selector syntax.
    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

    Selector matching is exact-match on every key in the entry, and entries are evaluated in order, so put more specific entries first.

  5. Run it in the foreground:

    go run main.go
    listening on :8080 with 2 entries

    The list is read once at startup. To change entries, edit entries.json and restart the process.

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.

Wire it into Defakto

Two changes are needed: The server must call the webhook, and the cluster's path template must consume the path the webhook returns.

First point the server at the webhook, in your second terminal. 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

Then set the path template for your test cluster so the returned path becomes the SPIFFE ID.

The ! prefix is required here. A substituted value may normally contain only letters, numbers, dots, dashes, and underscores, so a value like payments/checkout-api is rejected as an invalid segment. The marker lets the value carry its own separators and expand into more than one segment. See Path segment characters.

cat > svid-issuance-policy.yaml << 'EOF'
section: SVIDIssuancePolicy
schema: v1
spec:
policy:
pathTemplate: "/{{!custom.path}}"
EOF

Find the test cluster's ID with spirlctl cluster list. It is the c- prefixed value in the ID column:

spirlctl cluster list
Name ID Trust Domain Created
prod-us-east-1 c-ab49xuq9kh example.com 2026-08-05T03:34:37Z
registration-test c-4c4z9d3tjo example.com 2026-07-23T18:11:37Z

2 clusters found.

Apply the policy to that cluster. Confirm the ID belongs to the test cluster before running this, since the template change affects every workload in whichever cluster you name:

export CLUSTER_ID=<your-test-cluster-id>
spirlctl config set cluster --id "$CLUSTER_ID" svid-issuance-policy.yaml

Managed Configuration takes up to a minute to apply. The webhook change rolls the server pods; the path template does not.

Verify

Using the workload kubernetes cluster with the agent.

Create a workload matching the second entry. The service account must be checkout-api, and the container must be named workload for the selector to apply:

kubectl create namespace payments
kubectl -n payments create serviceaccount checkout-api
kubectl run svid-check -n payments -l "k8s.spirl.com/spiffe-csi=enabled" --restart=Never --image ghcr.io/spirl/spirldbg:latest --rm -it --overrides='{ "spec": { "serviceAccountName": "checkout-api" } }' -- spirldbg svid-jwt --audience extension-check

The webhook log shows the match:

received request with attributes:
kubernetes.pod.namespace=payments
kubernetes.pod.service_account=checkout-api
...
ALLOW ns=payments pod=svid-checker path=payments/checkout-api

And the SPIFFE ID shown will have the path from the entries.json file:

Successfully received JWT SVID
SPIFFE ID: spiffe://your-trust-domain/payments/checkout-api

Now confirm an unregistered workload is refused. Repeat with a service account that is not in the list:

kubectl -n payments create serviceaccount unregistered
kubectl run svid-check -n payments -l "k8s.spirl.com/spiffe-csi=enabled" --restart=Never --image ghcr.io/spirl/spirldbg:latest --rm -it --overrides='{ "spec": { "serviceAccountName": "unregistered" } }' -- spirldbg svid-jwt --audience extension-check
DENY ns=payments pod=svid-checker-unregistered: no matching entry

Clean up

Restore the cluster's default path template first, so workloads stop depending on the webhook:

cat > svid-issuance-policy.yaml << 'EOF'
section: SVIDIssuancePolicy
schema: v1
spec:
policy:
pathTemplate: "/{{cluster.name}}/ns/{{kubernetes.pod.namespace}}/sa/{{kubernetes.pod.service_account}}"
EOF
spirlctl config set cluster --id "$CLUSTER_ID" svid-issuance-policy.yaml

Then remove the extension and the webhook:

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

On the cluster with the agents:

kubectl delete namespace payments

On the cluster with the servers:

kubectl delete namespace defakto-extension

Next steps