Inbound: SPIFFE JWT-SVID
A JWT-SVID is a JWT carrying a SPIFFE identity. It is the
right credential for service-to-service authentication where mTLS
is impractical (the network path crosses a load balancer that does
not preserve client certificates, the calling service speaks a
protocol that does not support TLS client auth, the deployment
favours the simplicity of bearer tokens). The JwtSvidResolver is
the axess resolver that validates these tokens and produces a
Principal::Workload.
The feature flag is jwt-svid (off by default).
The credential shape
A SPIFFE JWT-SVID is an ordinary JWT with two specific claim
requirements. The subject (sub) claim is the SPIFFE ID, formatted
as spiffe://<trust_domain>/<path>. The audience (aud) claim
names the intended recipient: when your application validates the
token, the audience must match a configured value.
{
"iss": "https://spire.prod.example.com",
"sub": "spiffe://prod.example.com/svc/billing",
"aud": ["https://api.example.com"],
"exp": 1735689600,
"iat": 1735686000,
"jti": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
The signature is over the standard JWT body plus header, using keys published by the trust domain's issuing authority through a JWKS endpoint. The signing algorithm is RS256 or ES256 in production deployments; SPIFFE does not standardise the algorithm, but the keys advertised in the JWKS specify it.
Wiring it up
The resolver takes a verifier, a trust domain and a token, and nothing else.
Configuration
The resolver takes a verifier, the trust domain it accepts, and the token, and nothing else:
use std::sync::{Arc, RwLock};
use axess_factors::jwt::{svid::JwtSvidResolver, verifier::JwtVerifier};
let verifier = Arc::new(
JwtVerifier::new(Arc::new(RwLock::new(jwks)))
.with_issuer("https://spire.prod.example.com")
.with_audience("https://api.example.com")
.with_clock_skew(Duration::from_secs(30)),
);
let resolver = JwtSvidResolver::new(
verifier,
"prod.example.com".parse()?,
token,
);
let principal = resolver.resolve().await?;
Everything about how a token is validated (issuer, audience,
clock skew, which algorithms, whether a jti may be replayed)
belongs to the JwtVerifier
you hand it, and is configured there. The resolver adds the SPIFFE
rules on top: the trust domain, and the shape of the identity.
Where the key set comes from is likewise yours: a JwkSet read from
a file the deployment mirrors, or JwksCache (feature oidc) if you
want axess to fetch it. The resolver never fetches.
trust_domain is the trust domain the resolver accepts SVIDs
from. A token whose sub SPIFFE ID names a different trust domain
is rejected. The defence is the trust-domain isolation that SPIFFE
is built around.
expected_audiences and the clock (with_audience and
with_clock_skew on the verifier) are configured there rather than
here; see above. A token
whose aud does not match is rejected by the verifier before the resolver
sees it.
There is no max_token_age. An iat upper bound is not implemented: a
token is accepted until its exp, and bounding issuance age is the issuer's
to do through a short lifetime. If you need it, check VerifiedClaims::iat
yourself after verify.
Wiring the resolver
There is no JwtSvidLayer. axess ships no Tower middleware for SVIDs;
the resolver is a plain call you make where you like, which is what lets a
daemon with no Axum router use it at all:
use axess_identity::PrincipalResolver;
// `bearer_from` and its error are yours; axess has no opinion on how a
// request without a credential is refused.
let token = bearer_from(&headers).ok_or(MyError::NoCredential)?;
let resolver = JwtSvidResolver::new(verifier.clone(), trust_domain.clone(), token);
let principal = resolver.resolve().await?; // Principal::Workload
In an Axum application, call it in a middleware of your own and insert the
Principal into the request extensions; the bearer feature's
BearerTokenLayer is the shipped example of that shape, for plain bearers
rather than SVIDs.
What it checks, and what you get
Every rejection returns the same error, deliberately.
Validation details
The validation runs through six checks in order. The order matters because cheaper checks come first: a malformed token fails parsing without ever fetching JWKS keys; an expired token is rejected without engaging the signature check.
The first check is parsing. The token must be a well-formed JWT with header, payload, and signature segments. Malformed input is rejected without further work.
One thing to know before reading the rest: resolve reports every one
of these failures as IdentityError, because that is what
PrincipalResolver returns. A failed SPIFFE-ID decomposition or a
trust-domain mismatch surfaces as IdentityError::InvalidSpiffeId
with a message naming the problem; everything else collapses to
IdentityError::NotAuthenticated, with the underlying JWT error
logged at debug for operators. JwtSvidError does not exist. The
collapse is deliberate: a caller presenting a bad token learns only
that it was refused.
The second check is the header. The alg field must be one of
the configured allowed algorithms (RS256 or ES256 by default;
deployments that need others configure them explicitly). The
kid field must be present so the resolver can look up the right
key.
The third check is the claims. The sub claim must be a valid
SPIFFE URI under the configured trust domain. The aud claim
must contain at least one of the configured expected audiences.
The exp and iat claims must be present and within the clock
skew and max age bounds. Which claim failed appears in the debug log,
not in the returned error.
The fourth check is the signature. The resolver looks up the key
matching the token's kid in the cached JWKS, verifies the
signature, and falls through on success. A signature failure
triggers a JWKS cache refresh (subject to the debouncing) and a
retry against the fresh keys; a failure after refresh is final.
The fifth check is the nbf (not-before) claim when present.
SPIRE typically issues tokens with nbf slightly in the future to
allow for clock skew on the receiver side. The check uses the
same clock-skew tolerance.
The sixth check is the duplicate-jti check, when configured.
SPIFFE recommends a jti on each token so a receiver can detect
replay; a deployment that wants it implements JtiReplayStore and
hands it to the verifier with with_replay_store: a HashSet
behind a mutex for one process, a small Valkey cache for a fleet.
axess ships the trait and NoReplay, not a backend. With a store
configured, a token carrying no jti is rejected rather than
admitted unchecked, and entries expire with the token's own exp.
What the principal looks like
A successful validation produces a Principal::Workload:
Principal::Workload(WorkloadPrincipal {
workload_id: WorkloadId::new("spiffe://prod.example.com/svc/billing"),
trust_domain: TrustDomain::new("prod.example.com"),
issuer: Issuer::JwtSvid {
jwks_url: "https://spire.prod.example.com/keys".parse().unwrap(),
},
tenant_id: derive_tenant_from_path(...),
tenant_slug: derive_slug_from_path(...),
service_name: derive_service_from_path(...),
attributes: {
"exp": 1735689600,
"iat": 1735686000,
"jti": "f47ac10b-...",
},
})
The workload_id is the parsed SPIFFE URI. The trust_domain
mirrors the configured trust domain. The issuer records that
the principal came through the JWT-SVID path with the specific
JWKS URL. The tenant and service derivation depends on the
deployment's SPIFFE path convention (the example above expects
paths like /svc/<service>/<tenant>); the resolver's path-parsing
logic is configurable, and examples/local_idp/ demonstrates the
pattern.
The attributes map carries the rest of the token's claims, so
Cedar policies can match on them if needed (a policy that demands
a specific issuer signature, for instance, reads
principal.attributes.iss).
Threat model
The JWT-SVID flow is robust against the standard attacks when the validation is complete.
Against token forgery: the signature check defeats it. An attacker without the issuing authority's signing key cannot mint a valid SVID.
Against token theft: the audience check defeats most of it. A token stolen from one service cannot be used against another service whose audience does not match.
Against token replay: the token's own lifetime is the window, so a
short exp at the issuer is the control, since no issuance-age
bound is implemented here. With a JtiReplayStore configured,
replay is detected explicitly rather than merely bounded.
Against trust-domain confusion: the trust-domain match defeats cross-domain attacks. A token from a different trust domain is rejected without further consideration.
The remaining attack surface is the issuing authority itself. A compromised SPIRE control plane can mint compromised SVIDs, and no client-side check catches that. The defence is operational: secure the SPIRE control plane, monitor its audit log, rotate keys on a schedule.
Troubleshooting
Every rejection looks the same to the caller. resolve returns
IdentityError::NotAuthenticated whichever check failed: a bad
signature, an unknown key, a wrong audience, an expired token, a
replayed jti, a missing or malformed sub. That is deliberate,
and it mirrors the user-enumeration discipline the Authn surface
follows: a caller learns that it was refused, not what to change
to get past. Do not branch on the variant, and do not expect one
that names the cause, because there isn't one.
The cause goes to the log instead, at debug on the
axess_factors::jwt target. Turn that on and the rejected
verification prints the underlying JwtError:
RUST_LOG=axess_factors::jwt=debug
A key the JWKS does not advertise is the common one during SPIRE
rotation, where the cache debounce can hide a fresh key briefly;
force a refresh or wait out the TTL. An audience rejection means
the issuer mints a different aud than expected_audiences
lists. The token payload is base64 and readable, so decode it and
compare rather than guessing.
If the log shows the SPIFFE ID parsing or the trust domain, a workload from a different domain is calling your service. If this is intentional, configure federation (the next chapter, Inbound: federation, covers the mechanism). If it is not intentional, the workload is misconfigured.
Fetching SVIDs from a local SPIRE agent
JwtSvidResolver is the verifying side; it consumes an SVID
presented in an HTTP request and validates it against the trust
domain's JWKS. The issuing side; fetching fresh SVIDs from a
local SPIRE agent socket for outbound calls; is a separate
concern.
For deployments that need to fetch SVIDs at runtime, two adopter-direct options exist on crates.io today:
spire-workload; higher-level wrapper around the SPIRE Workload API gRPC, including JWT-SVID fetch with auto-rotation. Most adopters reach for this first.spire-api; lower-level generated gRPC client when finer control is needed.
axess does not currently wrap either crate; the SPIRE Workload API
client on the ROADMAP (feature spire) lands when an adopter
needs an axess-shaped surface (e.g. integration with axess-clock
for rotation timing, axess-rng for ceremony nonces, or the
Principal::Workload shape on the fetch result for symmetry with
the verifier). Until then, the recommended path is:
- Use
spire-workloaddirectly in your application to fetch JWT-SVIDs against a configured audience. - Present the fetched SVID on outbound calls via your HTTP client.
- On the receiving service, validate the SVID with
JwtSvidResolveras documented above. The presenting and verifying sides interoperate without axess wrapping the fetch side.
If your deployment forces the issue (e.g. fetch-side rotation needs to drive axess-clock-pinned tests), open a tracking issue; that's exactly the adopter-demand signal the ROADMAP entry waits for.
Further reading
Workload identity overview covers the SPIFFE model and the
unified Principal type this resolver produces. Inbound:
mTLS-SVID covers the X.509 variant for deployments where mTLS is
practical. Inbound: federation covers the cross-trust-domain
patterns. Cedar policy fundamentals covers how policies match on
the workload's claims through principal.attributes.