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.
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.
| Attribute | Description |
|---|---|
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:
| Attribute | Value |
|---|---|
custom_jwt.sub | checkout-api |
custom_jwt.environment | production |
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_jwtto matchtype: custom_jwtin 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
issclaim must exactly match the policy'sissuer, and itsaudclaim must be one ofallowedAudiences(urn:defakto:security:serverby 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_ATTESTORSenvironment variable does not support Custom JWT. UseAttestingWorkloadAPIClientwith 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:
| Error | Likely cause |
|---|---|
no policy matched the provided attestations | No 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 required | The attestor produced an empty payload |
unexpected proof version | The attestor's plugin version is not 1.0 |
token is not a valid JWT | The payload is not a JWS compact serialization, or the token is signed with an algorithm outside allowedAlgorithms |
fetch signing key | The 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 JWT | Signature, issuer, audience, or expiry validation failed |
required claim ... is not present in token | A claimRequirements entry names a claim the token does not carry |
claim ... value is not in the list of allowed values | A claimRequirements entry did not match. Compare the token's actual claim values against the policy |
exceeding the limit of | A 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.
| Field | Description |
|---|---|
oidcURI | OIDC issuer base URI. The server discovers the JWKS endpoint via /.well-known/openid-configuration. Must use HTTPS. |
jwksURI | Direct JWKS endpoint URL. Must use HTTPS. |
jwks | Inline JWKS document (JSON). Must contain only public keys. |
jwksPEM | PEM-encoded public key(s). Must contain only public keys. |
Server Configuration Reference
| Field | Required | Default | Description |
|---|---|---|---|
issuer | Yes | — | Expected iss claim. Tokens with a different issuer are rejected. |
allowedAudiences | No | urn:defakto:security:server | Accepted aud values. |
allowedAlgorithms | No | All asymmetric algorithms (RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA) | Accepted signing algorithms. HMAC (HS*) and none are always rejected. |
claimRequirements | No | — | Claim 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. |
attributeClaims | No | — | Claim paths to extract as workload attributes. Same path syntax as claimRequirements. |
maxAttributesPerClaim | No | 10 | Maximum attributes a single claim may produce after expansion. Attestation is rejected if exceeded. |
jwksFetchInterval | No | 1m | Minimum interval between JWKS fetches. Go duration string (minimum 1m). Only valid with oidcURI or jwksURI. |
jwksCacheTTL | No | 24h | Default 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.
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,~1escapes a literal/and~0escapes 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
/repositorywith/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.
allowedAudiencesprevents 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
explimit the value of a leaked token. - Combine methods where possible. Requiring a platform method alongside
custom_jwtin 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 thenonealgorithm are unconditionally rejected, regardless ofallowedAlgorithmsconfiguration. - 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.