Skip to main content

Custom JWT

The Custom JWT method attests serverless workloads using a JSON Web Token (JWT) from any OIDC-compatible or custom issuer. The workload submits its token through the SDK. The Trust Domain Server validates the token's signature, issuer, and audience, optionally enforces claim requirements, and issues a SPIFFE Verifiable Identity Document (SVID) whose path is built from the token's claims.

Use this method when the workload's platform has no built-in serverless attestation method but can obtain a JWT from an issuer you trust, for example:

  • CI/CD systems that mint OIDC tokens for jobs.
  • On-premises platforms, or clouds without a dedicated serverless method.
  • An internal identity provider that already issues tokens to your workloads.
note

This method is used during serverless attestation, where workloads request SVIDs directly from the Trust Domain Server. To attest an agent with a JWT, see Agent Attestation — Custom JWT. Both use the same server-side validation and configuration fields. Only the way the token reaches the server differs: The agent reads it from a file or command, while a serverless workload supplies it through the SDK.

Attributes available for SVID issuance

Custom JWT attributes are user-defined. The server extracts the claims listed in attributeClaims and exposes them with the custom_jwt origin. Nested claims are flattened with . separators, and array-valued claims produce one attribute per element under the same name.

AttributeDescription
custom_jwt.<claim>Value of a claim listed in attributeClaims

For example, attributeClaims: ["sub", "environment", "/kubernetes.io/namespace"] against a token containing {"sub": "checkout-api", "environment": "production", "kubernetes.io": {"namespace": "payments"}} produces:

AttributeValue
custom_jwt.subcheckout-api
custom_jwt.environmentproduction
custom_jwt."kubernetes.io.namespace"payments

Example SPIFFE ID path template using these attributes:

/serverless/{{custom_jwt.environment}}/{{custom_jwt.sub}}

These attributes are also available in X.509-SVID customization and JWT-SVID additional claims.

How to Deploy

Step 1 — Update trust domain configuration

Add type: custom_jwt as a required attestor in a ServerlessAttestation policy. Specify exactly one key source and the expected issuer.

The examples below cover the common cases. For every available option, see Key Source Options and the Server Configuration Reference.

Basic example using OIDC discovery:

section: ServerlessAttestation
schema: v1
spec:
policies:
- name: custom_jwt_policy
svidPolicy:
pathTemplate: "/serverless/{{custom_jwt.environment}}/{{custom_jwt.sub}}"
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 trust-domain --id <trust-domain-id> serverless.yaml

Or using Terraform:

resource "spirl_trust_domain_config" "serverless_attestation" {
trust_domain_id = spirl_trust_domain.my_trust_domain.id
sections = {
ServerlessAttestation = <<-YAML
section: ServerlessAttestation
schema: v1
spec:
policies:
- name: custom_jwt_policy
svidPolicy:
pathTemplate: "/serverless/{{custom_jwt.environment}}/{{custom_jwt.sub}}"
requiredAttestors:
- type: custom_jwt
config:
oidcURI: https://idp.example.com
issuer: https://idp.example.com
attributeClaims:
- sub
- environment
YAML
}
}

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

Advanced example with a JWKS endpoint and claim requirements:

section: ServerlessAttestation
schema: v1
spec:
policies:
- name: ci_jobs
svidPolicy:
pathTemplate: "/serverless/ci/{{custom_jwt.sub}}"
requiredAttestors:
- type: custom_jwt
config:
jwksURI: https://ci.example.com/.well-known/jwks.json
issuer: https://ci.example.com
allowedAudiences:
- urn:defakto:security:server
allowedAlgorithms:
- ES256
claimRequirements:
environment:
- production
/repository/owner:
- my-org
attributeClaims:
- sub
- environment
maxAttributesPerClaim: 10

Inline key sources are also supported. Use jwks for a JWKS document or jwksPEM for PEM-encoded public keys when the issuer's keys are static and its endpoints are not reachable from the Trust Domain Server.

Step 2 — Submit the token from the workload

The Defakto SDKs cannot automatically retrieve the JWT token because it depends on the issuer and platform in use.

To submit the JWT token implement the SDK's Attestor interface so that the serialized JWT is set as the payload. Then set the custom attestor interface on the AttestingWorkloadAPIClient options.

See SDK Examples — Custom JWT for complete Python and TypeScript examples.

Requirements for the attestor:

  • The plugin name must be custom_jwt to match type: custom_jwt in the policy.
  • The plugin version must be 1.0. Other versions are rejected by the server.
  • The payload must be the token in JWS compact serialization, and must be non-empty. The serverless API rejects attestations without evidence.
  • The token's iss claim must exactly match the policy's issuer, and its aud claim must be one of allowedAudiences (urn:defakto:security:server by default).
  • The SDK collects evidence on every request and on every SVID rotation, so fetch or re-read the token each time rather than caching it for the lifetime of the process.
  • The DEFAKTO_ATTESTORS environment variable does not support Custom JWT. Use AttestingWorkloadAPIClient with an explicit attestor list.

Step 3 — Verify

Workload-visible errors — every attestation failure is returned to the workload as gRPC Unauthenticated, with no detail about which check failed. Diagnose failures from the Trust Domain Server logs.

Server logs — a successful attestation logs Policy matched with the policy name at debug level. A failure logs Policy matching failed with the underlying error, prefixed by failed to process requirement of type custom_jwt.

Metrics — confirm proofs are succeeding:

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

The outcome label carries only two values, success and failed. Alert on outcome="failed" to detect token validation failures:

spirl_attestation_signer.proof{attestor_type="custom_jwt",outcome="failed"}

Common errors:

ErrorLikely cause
no policy matched the provided attestationsNo policy authorized the submission. Either the workload did not submit every method some policy requires, the configuration hasn't synced yet, or a policy's required methods were all present but the token itself was rejected. Check the preceding log lines for the specific failure
invalid attestation: evidence is requiredThe attestor produced an empty payload
unexpected proof versionThe attestor's plugin version is not 1.0
token is not a valid JWTThe payload is not a JWS compact serialization, or the token is signed with an algorithm outside allowedAlgorithms
fetch signing keyThe Trust Domain Server cannot reach oidcURI or jwksURI, or no key matches the token's kid. Verify network connectivity and that the URL uses HTTPS
verify JWTSignature, issuer, audience, or expiry validation failed
required claim ... is not present in tokenA claimRequirements entry names a claim the token does not carry
claim ... value is not in the list of allowed valuesA claimRequirements entry did not match. Compare the token's actual claim values against the policy
exceeding the limit ofA claim expanded to more attributes than maxAttributesPerClaim allows. Raise the limit or narrow the attributeClaims path

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 reachable by the 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.

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 issuance to tokens from a particular environment, repository, or service account.

warning

Leaving claimRequirements unset accepts any validly signed, unexpired token from the configured issuer. When the issuer is public or multi-tenant, that is every token it mints for anyone. For example, a policy that trusts GitHub Actions OIDC with no claim requirements can be attested by a workflow in any GitHub repository in the world, not just yours. allowedAudiences is not a substitute, because issuers such as GitHub Actions let the caller choose the aud value of the token they request. Always constrain at least one claim that identifies your workload, such as repository, sub, or an equivalent tenant identifier.

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

Both claimRequirements keys and attributeClaims entries use the same path syntax:

  • 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, /repository/owner) 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 (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 /repository with /repository/owner).
  • 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).

For attributeClaims, a missing path produces no attribute rather than an error.

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.

Security Considerations

  • The token is the credential. Any workload that can obtain a token from the configured issuer can attest. Unlike platform methods such as AWS Web Identity Token or GCP Instance Identity Token, the Trust Domain Server has no independent view of where the workload runs.
  • Always set claimRequirements. Without them, any validly signed, unexpired token from the configured issuer is accepted, no matter which workload it was minted for.
  • Scope the audience. allowedAudiences prevents tokens minted for other services from being replayed against the Trust Domain Server. Keep the default audience, or configure a dedicated one your issuer only mints on request.
  • Use short-lived tokens. Evidence is collected fresh on every request and rotation, so tokens with a short exp limit the value of a leaked token.
  • Combine methods where possible. Requiring a platform method alongside custom_jwt in the same policy binds the token to an attested platform identity. Both proofs must then be submitted for the policy to match.
  • 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.

Troubleshooting

Issuer mismatch — The iss claim in the JWT must exactly match the issuer value in the trust domain configuration, including scheme and any trailing path. Enable debug logging on the server to see the rejected token's claims.

Unknown signing key — The server cannot find a key matching the JWT's kid header. For jwksURI and oidcURI, the server refreshes keys automatically on an unknown kid, subject to jwksFetchInterval. For jwks and jwksPEM, adding a new key requires a trust domain configuration update.

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.

Attribute not available in the SPIFFE ID — Verify the path in attributeClaims. Entries starting with / are parsed as JSON Pointers per RFC 6901. Entries not starting with / are taken as literal top-level claim names, with no escaping. A missing path produces no attribute. If the path template references that attribute, attestation succeeds but issuance fails with access denied, and the server logs Failed to render SPIFFE ID with the missing attribute names.

Policy selection — When several policies list custom_jwt, the server first narrows to the policies whose required attestors the workload actually submitted, then tries the most specific of those first. Specificity is the number of required attestors: A policy requiring custom_jwt plus a platform method is a stricter match than one requiring custom_jwt alone, so it is evaluated first and the looser policy cannot shadow it. Declaration order only breaks ties between policies that require the same number of attestors. When a tie is broken this way, the server logs the selected policy alongside the equally-specific policies it did not try, so reordering the list is a visible way to change the outcome.