Skip to main content

Keyless Server Authentication

A self-hosted Trust Domain Server must authenticate before the Defakto Control Plane lets it join your Trust Domain and start issuing identities. There are two ways to do that.

MethodWhat the server holdsStatus
Kubernetes ServiceAccount tokenNo long-lived private key. Kubernetes issues a fresh, short-lived token at each login.Recommended
Deployment key (tdk-*)A long-lived private key you generate, store, and ship to the cluster.Supported, not recommended

A deployment key is the credential a server logs in with. An older name for it, "trust domain key," is easy to confuse with the signing keys the server uses to issue SVIDs, so these docs avoid it. Some spirlctl output still uses the older name.

Keyless is better because there is no private key to generate, paste into a Helm values file, copy into a secret manager, rotate, or leak. The server proves its identity with its Kubernetes ServiceAccount, a credential Kubernetes already manages and rotates.

See this guide to migrate off deployment keys.

How It Works

A TrustDomainServerAttestation configuration tells the Control Plane, ahead of time, which Kubernetes identity is allowed to bring up a Trust Domain Server.

When the server starts:

  1. It asks Kubernetes for a fresh token for its own ServiceAccount.
  2. It sends that token to the Control Plane along with its (non-secret) deployment ID.
  3. The Control Plane looks up the TrustDomainServerAttestation policy and confirms the token came from the configured issuer and belongs to the expected ServiceAccount. To check the token's signature the Control Plane needs the cluster's public keys, and there are two ways it gets them:
    • The issuer is reachable from the Control Plane. The Control Plane fetches the cluster's JWKS from issuerURL over HTTPS. Key rotation is picked up automatically.
    • The issuer is not reachable, as on a private or air-gapped cluster. You paste the cluster's JWKS into the policy, and the Control Plane verifies the token without any network call. You have to update the policy yourself when the cluster rotates its keys.
  4. The Control Plane replies with a single-use challenge (a nonce). The server asks Kubernetes for a second token bound to that challenge and sends it back. A stolen token cannot pass this step, so a login cannot be replayed.
  5. The Control Plane verifies the challenge-bound token the same way, then issues the server its identity.

Which case you are in decides what you collect in Step 1 and what you put in the policy in Step 2.

No static key appears anywhere in that flow. Keyless is safe because an administrator pre-registers the allowed identity, so the Control Plane already knows who is permitted to log in.

What keeps this secure

  • There is no static key to steal. The policy names the exact ServiceAccount allowed to log in, so a token from any other identity is rejected.
  • Every login is bound to a single-use, server-issued challenge, so a captured token cannot be replayed.
  • Tokens use an audience reserved for the Control Plane, so a token issued for an agent cannot be used to log in a server.

Setting It Up

Step 1 — Collect the values you need

You always need the issuer URL, the namespace, and the ServiceAccount name. You also need the cluster's public signing keys (JWKS), but only if the Control Plane cannot reach the issuer over the public internet, as on a private or local cluster.

The cluster's OIDC issuer URL:

kubectl get --raw /.well-known/openid-configuration | jq -r .issuer

On EKS you can also read it from the cluster description:

aws eks describe-cluster --name my-cluster \
--query "cluster.identity.oidc.issuer" --output text

The cluster's public signing keys (JWKS):

kubectl get --raw /openid/v1/jwks | jq .

This document contains only public keys. It is safe to paste into configuration.

The namespace and ServiceAccount name of the Trust Domain Server. The Helm chart names the ServiceAccount after the release, which is the deployment ID:

kubectl get serviceaccount -n tdd-nnlo6k3t3o-example
NAME SECRETS AGE
tdd-nnlo6k3t3o-spirl-server 0 3d

Step 2 — Register the allowed identity

Create a TrustDomainServerAttestation document naming the OIDC issuer for the Trust Domain's Kubernetes cluster, plus the ServiceAccount that is allowed to start a server:

cat > trust-domain-server-attestation.yaml <<EOF
section: TrustDomainServerAttestation
schema: v1
spec:
requiredAttestors:
- type: k8s_token
config:
issuerURL: https://oidc.example.com
serviceAccountNamespace: tdd-nnlo6k3t3o-example
serviceAccountName: tdd-nnlo6k3t3o-spirl-server
EOF

Check the document before you apply it. config validate reports any problem without changing anything:

spirlctl config validate trust-domain-deployment \
--id tdd-nnlo6k3t3o trust-domain-server-attestation.yaml

Apply it to the trust domain deployment, then read it back to confirm what is stored:

spirlctl config set trust-domain-deployment \
--id tdd-nnlo6k3t3o trust-domain-server-attestation.yaml

spirlctl config get trust-domain-deployment \
--id tdd-nnlo6k3t3o
Give it a moment to propagate

The policy usually reaches the Control Plane's authentication path within about 30 seconds. If the change just misses a sync cycle, it can take up to five minutes. Set the policy before you start the server, and if a first login fails, wait and let the server retry before changing anything.

Using different --id values, you can set up a separate configuration for each deployment.

Supplying the signing keys inline. When the issuer is not reachable from the Control Plane, add the cluster's JWKS to the same document. The jwks field takes the JWKS document as a JSON string, so it has to be quoted and escaped. jq will do that for you:

cat > trust-domain-server-attestation.yaml <<EOF
section: TrustDomainServerAttestation
schema: v1
spec:
requiredAttestors:
- type: k8s_token
config:
issuerURL: https://oidc.example.com
serviceAccountNamespace: tdd-nnlo6k3t3o-example
serviceAccountName: tdd-nnlo6k3t3o-spirl-server
jwks: $(kubectl get --raw /openid/v1/jwks | jq -c . | jq -Rs 'rtrimstr("\n")')
EOF

Print the file and confirm the jwks value is a single quoted string before you apply it.

Using Terraform:

resource "spirl_trust_domain_deployment_config" "trust_domain_server_attestation" {
trust_domain_deployment_id = spirl_trust_domain_deployment.demo_deployment.id
sections = {
TrustDomainServerAttestation = <<-YAML
section: TrustDomainServerAttestation
schema: v1
spec:
requiredAttestors:
- type: k8s_token
config:
issuerURL: https://oidc.example.com
serviceAccountNamespace: tdd-nnlo6k3t3o-example
serviceAccountName: tdd-nnlo6k3t3o-spirl-server
YAML
}
}

Configuration Reference

Only k8s_token is allowed as an attestor type.

FieldRequiredDescription
issuerURLYesThe expected token issuer. By default, also where Defakto fetches the signing keys.
serviceAccountNamespaceYesNamespace of the Trust Domain Server's ServiceAccount.
serviceAccountNameYesName of the Trust Domain Server's ServiceAccount.
jwksURINoDirect JWKS endpoint URL. When set, OIDC discovery is skipped and issuerURL is only matched literally against the iss claim. Must use HTTPS. Mutually exclusive with jwks.
jwksNoInline JWKS document (JSON string containing only public keys). When set, no network fetch is performed and issuerURL is only matched literally against the iss claim. Mutually exclusive with jwksURI.

Use jwksURI or jwks when the cluster's issuer is not reachable from the Control Plane, for example on a private cluster.

Step 3 — Turn on keyless auth in the Helm chart

trustDomainDeployment:
id: tdd-nnlo6k3t3o
trustDomainName: "spirl.example.com"
trustDomainID: "td-diok4t8ahq"
name: "us-west-2"
controlPlane:
auth:
k8sToken:
enabled: true

Roll it out. The Helm release name is the deployment ID, and the chart names the Kubernetes Deployment after it:

helm upgrade tdd-nnlo6k3t3o oci://ghcr.io/spirl/charts/spirl-server \
-n tdd-nnlo6k3t3o-example -f values.yaml

kubectl -n tdd-nnlo6k3t3o-example rollout status deploy/tdd-nnlo6k3t3o-spirl-server

The rollout can take a few minutes to report success.

The chart also grants the server permission to issue tokens for its own ServiceAccount, which is what lets it answer the Control Plane's challenge. No extra RBAC work is needed.

Verifying

In the logs

Every login attempt names the credential it used in the authMethod field. Check the Trust Domain Server logs for k8s_token:

kubectl -n tdd-nnlo6k3t3o-example logs deploy/tdd-nnlo6k3t3o-spirl-server \
| grep -E "Authentication succeeded|Authentication failed|fallback auth method"

Success looks like this:

{"level":"info","msg":"Authentication succeeded","authMethod":"k8s_token"}

A failure looks like this:

{"level":"warn","msg":"Authentication failed","authMethod":"k8s_token","error":"<reason>"}

In the metrics

The server counts each established session by the method that got it in, so a non-zero k8s_token count is proof keyless worked. Port-forward the metrics port:

kubectl -n tdd-nnlo6k3t3o-example port-forward deploy/tdd-nnlo6k3t3o-spirl-server 19090:9090

Then, in a second terminal:

curl -s localhost:19090/metrics | grep spirl_session
spirl_session_established_total{auth_method="k8s_token"} 1

Metrics have to be enabled on the deployment. See Trust Domain Server Metrics.

In the Console

The stored policy is also visible in the Defakto Console. Open Trust Domains, pick your Trust Domain, then SettingsDeployment Configurations, click into the deployment, and open the Server Attestation tab.

Troubleshooting

  • Authentication failed with a JWKS or signing key error: The Control Plane cannot reach issuerURL. Confirm the issuer is publicly resolvable over HTTPS, or supply the keys directly with jwksURI or jwks.
  • Authentication failed naming the sub claim: The serviceAccountNamespace or serviceAccountName in the TrustDomainServerAttestation document does not match the ServiceAccount the pod actually runs as. Compare against kubectl get serviceaccount -n <namespace>.
  • No k8s_token line in the logs at all: controlPlane.auth.k8sToken.enabled is not set. Confirm the value made it into the release via helm get values tdd-nnlo6k3t3o -n tdd-nnlo6k3t3o-example.