Skip to main content

Custom JWT

The Custom JWT method authenticates agents using a JSON Web Token (JWT) from any OIDC-compatible or custom issuer. The agent presents a JWT to the Trust Domain Server. The server validates its signature, issuer, audience, and optionally enforces claim requirements.

This is a general-purpose attestation method for environments without an underlying platform, but can acquire a JWT from a trusted issuer, such as on-premises nodes, or CI/CD systems.

How to Deploy

Step 1 — Update cluster configuration

Configure the AgentAttestation policy with the JWT validation parameters. Specify exactly one key source and the expected issuer.

Basic example using OIDC discovery:

section: AgentAttestation
schema: v1
spec:
policies:
- name: custom_jwt_policy
requiredAttestors:
- type: custom_jwt
config:
oidcURI: https://idp.example.com
issuer: https://idp.example.com
attributeClaims:
- sub
- environment

Apply it using spirlctl:

spirlctl config set cluster --id <cluster-id> attestation-policy.yaml

Or using Terraform:

resource "spirl_cluster_config" "agent_attestation" {
cluster_id = spirl_cluster.my_cluster.id
sections = {
AgentAttestation = <<-YAML
section: AgentAttestation
schema: v1
spec:
policies:
- name: custom_jwt_policy
requiredAttestors:
- type: custom_jwt
config:
oidcURI: https://idp.example.com
issuer: https://idp.example.com
attributeClaims:
- sub
- environment
YAML
}
}

Once a configuration document passes validation and is stored, the Defakto control plane syncs it to your Trust Domain Servers automatically. No server or agent restart is required.

Example using a JWKS URI:

section: AgentAttestation
schema: v1
spec:
policies:
- name: custom_jwt_policy
requiredAttestors:
- type: custom_jwt
config:
jwksURI: https://idp.example.com/.well-known/jwks.json
issuer: https://idp.example.com
attributeClaims:
- sub
- environment

Example using PEM public keys:

section: AgentAttestation
schema: v1
spec:
policies:
- name: custom_jwt_policy
requiredAttestors:
- type: custom_jwt
config:
jwksPEM: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEf83OJ3D2xF1Bg8vub9tLe1gHMzV7
6e8Tus9uPHvRVEUx/FEzRu9m36HLN/tue659LNpXW6pCyStikYjKIWI5a0==
-----END PUBLIC KEY-----
issuer: https://idp.example.com
attributeClaims:
- sub
- environment

Advanced example with claim requirements and inline JWKS:

section: AgentAttestation
schema: v1
spec:
policies:
- name: custom_jwt_policy
requiredAttestors:
- type: custom_jwt
config:
jwks: '{"keys":[{"kty":"EC","crv":"P-256","x":"...","y":"..."}]}'
issuer: https://ci.example.com
allowedAudiences:
- urn:defakto:security:server
allowedAlgorithms:
- ES256
claimRequirements:
environment:
- production
- staging
/kubernetes.io/namespace:
- critical-workloads
attributeClaims:
- sub
- environment
- /kubernetes.io/namespace
maxAttributesPerClaim: 10

Key Source Options

Key sources are mutually exclusive. Exactly one of these must be specified. The OIDC issuer URL or JWKS endpoint URL must be accessible by trust domain servers.

FieldDescription
oidcURIOIDC issuer base URI. The server discovers the JWKS endpoint via /.well-known/openid-configuration. Must use HTTPS.
jwksURIDirect JWKS endpoint URL. Must use HTTPS.
jwksInline JWKS document (JSON). Must contain only public keys.
jwksPEMPEM-encoded public key(s). Must contain only public keys.

Server Configuration Reference

FieldRequiredDefaultDescription
issuerYesExpected iss claim. Tokens with a different issuer are rejected.
allowedAudiencesNourn:defakto:security:serverAccepted aud values.
allowedAlgorithmsNoAll asymmetric algorithms (RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA)Accepted signing algorithms. HMAC (HS*) and none are always rejected.
claimRequirementsNoClaim path to list of allowed values. All entries must match. Keys use bare names for top-level claims or /-prefixed RFC 6901 JSON Pointers for nested claims.
attributeClaimsNoClaim paths to extract as workload attributes. Same path syntax as claimRequirements.
maxAttributesPerClaimNo10Maximum attributes a single claim may produce after expansion. Attestation is rejected if exceeded.
jwksFetchIntervalNo1mMinimum interval between JWKS fetches. Go duration string (minimum 1m). Only valid with oidcURI or jwksURI.
jwksCacheTTLNo24hDefault cache TTL for fetched JWKS, used when the issuer does not respond with cache-control headers. Go duration string (minimum 1m). Only valid with oidcURI or jwksURI.

Step 2 — Configure the Agent

The agent must be configured with a token source: Either a file path, or a command that outputs the JWT to stdout. The token is read fresh on every attestation.

note

When using a file path (tokenPath), another process must be responsible for rotating the on-disk token.

File-based token:

agent:
auth:
clusterId: c-xxxxxx
attestors:
- type: custom_jwt
config:
tokenPath: /var/run/tokens/agent-jwt

Command-based token:

agent:
auth:
clusterId: c-xxxxxx
attestors:
- type: custom_jwt
config:
tokenCommand:
- /usr/local/bin/fetch-token
- --audience
- urn:defakto:security:server

Agent Configuration Reference

FieldRequiredDescription
tokenPathOne of tokenPath or tokenCommandPath to a file containing the JWT. Read on each attestation attempt.
tokenCommandOne of tokenPath or tokenCommandCommand and arguments to execute. The JWT is read from stdout.

Step 3 — Verify

Server logs — look for these in order:

  1. "Login started with multi-attestation support" — confirms the agent offered providedMethods: ["custom_jwt"]
  2. "Authorization received and verified" — includes agentAttestationAttributes with extracted claims:
    {
    "msg": "Authorization received and verified",
    "agentAttestationAttributes": [
    "custom_jwt:custom_jwt.sub=\"ci-runner-7\"",
    "custom_jwt:custom_jwt.environment=\"production\""
    ]
    }
  3. "Connected to agent" — session is fully established

Agent logs — enable debug logging to see "Sending Login" with attestors: ["custom_jwt"]. At the default log level, "Connected to server" confirms the session is live.

Metrics — confirm proofs are succeeding:

spirl_attestation_signer.proof{attestor_type="custom_jwt",outcome="success"}
spirl_attestation_agent.proof{attestor_type="custom_jwt",outcome="success"}

Alert on outcome="failed" to detect token validation failures.

Common errors:

ErrorLikely cause
no cluster policy authorizes the provided attestorsNo policy includes custom_jwt, or the cluster configuration hasn't synced yet
Attestor rejected proof, policy failedToken validation failed — issuer, audience, algorithm, or claim requirements don't match
failed to fetch signing keyThe Trust Domain Server cannot reach oidcUri or jwksUri. Verify network connectivity and that the URL uses HTTPS.
claim requirement not satisfiedA claimRequirements entry didn't match. Check the token's actual claim values against the policy.
attribute expansion exceeded limitA claim produced more than maxAttributesPerClaim attributes. Increase the limit or narrow the attributeClaims path.
token is expiredThe JWT's exp claim is in the past. Ensure the token source produces fresh tokens.

Security Considerations

  • HTTPS enforcement: Remote key sources (oidcURI, jwksURI) must use HTTPS. URLs that resolve to loopback, private, or link-local IP addresses are rejected to prevent SSRF.
  • Algorithm restrictions: HMAC algorithms (HS256, HS384, HS512) and the none algorithm are unconditionally rejected, regardless of allowedAlgorithms configuration.
  • No private key material: Inline key sources (jwks, jwksPEM) are validated at configuration time to ensure they contain only public keys.
  • Token freshness: The agent reads the token fresh on every attestation attempt. Use short-lived tokens and ensure your token source rotates them before expiry.
  • Claim requirements: Use claimRequirements to restrict which tokens are accepted beyond signature and issuer verification. Without claim requirements, any validly-signed token from the configured issuer is accepted.
  • Command execution: tokenCommand runs with the same privileges as the agent process.

Claim Requirements

claimRequirements gates attestation on the contents of the JWT: the token must contain every listed claim with at least one matching value, otherwise attestation is rejected before any attributes are produced. Use it to constrain which tokens from a trusted issuer are allowed to attest — for example, restricting attestation to tokens from a particular environment, namespace, or service account.

This is distinct from attributeClaims: attributeClaims controls which claim values are exposed for SPIFFE ID construction, while claimRequirements controls whether attestation is allowed to proceed at all. The two are independent — a claim can be required without being exposed, or exposed without being required.

Path syntax

Keys use the same path syntax as attributeClaims:

  • A bare name (e.g. env, kubernetes.io) is treated as a literal top-level claim name. Dots in the name are part of the name, not separators.
  • A leading / (e.g. /kubernetes.io/namespace, /address/country) is parsed as an RFC 6901 JSON Pointer for traversing nested objects. Within a JSON Pointer, ~1 escapes a literal / and ~0 escapes a literal ~.

Match semantics

  • Scalar value (string, number, boolean) — matches if the value, stringified, equals any of the allowed values. Numbers and booleans are stringified before comparison (e.g. true"true", 42"42").
  • Array — matches if any scalar element in the array equals any allowed value (set intersection).
  • Object value — rejected at attestation time with an error. Gate on a scalar leaf instead (e.g. replace /address with /address/country).
  • Missing path or null value — fails the requirement; attestation is rejected.

All specified requirements must pass for attestation to succeed (logical AND across keys, logical OR within each key's allowed value list).

Attributes available for SVID issuance

Custom JWT attributes are user-defined. The server extracts the claims specified in attributeClaims and exposes them with the custom_jwt namespace. Nested claims are flattened with . separators.

These attributes are available in SPIFFE ID path templates, X.509 SVID customization, and JWT SVID additional claims.

For example, configuring attributeClaims: ["sub", "environment", "/kubernetes.io/namespace"] on a token with {"sub": "ci-runner-7", "environment": "production", "kubernetes.io": {"namespace": "default"}} produces:

AttributeValue
custom_jwt.subci-runner-7
custom_jwt.environmentproduction
custom_jwt."kubernetes.io.namespace"default

Array-valued claims produce one attribute per element under the same name.

Example SPIFFE ID template:

/custom/{{custom_jwt.sub}}/{{custom_jwt.environment}}

Examples

Gate on environment and namespace:

claimRequirements:
env:
- production
/kubernetes.io/namespace:
- spirl-agents
- spirl-system

A token with env=production and kubernetes.io.namespace=spirl-agents attests successfully. A token with env=staging is rejected; so is a token with env=production but kubernetes.io.namespace=default.

Gate on group membership (array claim):

claimRequirements:
groups:
- platform
- infra

A token with "groups": ["platform", "developers"] attests successfully because platform is in both the token's claim and the allowed list. A token with "groups": ["developers"] is rejected.

Go Duration Strings

Duration-typed fields (jwksFetchInterval, jwksCacheTtl) accept Go duration strings: a decimal number followed by a unit suffix — ns, us (or µs), ms, s, m, or h. Examples: 30s, 5m, 1h30m.

Troubleshooting

Agent fails to read token — If using tokenPath, verify the file exists and is readable by the agent process. If using tokenCommand, verify the binary is executable.

Issuer mismatch — The iss claim in the JWT must exactly match the issuer value in the server configuration. Enable debug logging on the server to see the actual iss claim value.

Unknown signing key — The server cannot find a key matching the JWT's kid header. For jwksUri and oidcUri, the server automatically attempts to refresh keys on an unknown kid. For jwks and jwksPem, a cluster config update is required to add new keys.

Algorithm rejected — Symmetric algorithms (HS256, HS384, HS512) and none are unconditionally rejected. Ensure your issuer signs with an asymmetric algorithm such as RS256 or ES256.

Claim requirements fail unexpectedlyclaimRequirements keys use bare names for top-level claims and /-prefixed JSON Pointers for nested claims. For example, use /kubernetes.io/namespace to match {"kubernetes.io": {"namespace": "default"}}. Check that array claims contain at least one value from the allowed set.

Claim requirement rejected on object value — A claimRequirements key resolved to an object. Object-valued requirements are not supported. Replace with one requirement per scalar leaf (e.g. replace /address with /address/country).

Attribute not available — Verify the path in attributeClaims. Entries starting with / are parsed as JSON Pointers per RFC 6901, where ~1 escapes a literal / and ~0 escapes a literal ~. Entries not starting with / are taken as literal top-level claim names — no escaping applies. Missing paths produce no attribute rather than an error.

Too many attributes — A single attributeClaims entry expanded to more attributes than maxAttributesPerClaim allows. Narrow the path to a more specific subtree, or raise maxAttributesPerClaim in the cluster config.