Actor JWTs
What an actor JWT contains and how a relying party verifies it.
ate-api-server mints short-lived JWTs that identify an actor, and ate-idp-server publishes the keys that verify them. A relying party, such as a cloud provider’s token exchange or a service the actor calls, trusts a token by checking it against the issuer’s OpenID Connect discovery document. This page describes what a token contains and how a relying party verifies it.
Tokens come from the Control.MintActorJwt RPC. The egress gateway calls it
to attach an actor JWT to an actor’s outbound requests.
Verifying a token
A relying party is configured with the issuer URL it trusts and the audience it expects. To verify a token:
- Check that the
issclaim equals the trusted issuer. - Fetch
<issuer>/.well-known/openid-configurationover TLS, verifying the server’s certificate, and check that itsissuerequals the same value. See Reaching the issuer for how to trust the certificate. - Fetch the document’s
jwks_uriover TLS with the same certificate check, and take the key whosekidmatches the token header. Check the signature with that key and itsalg. - Check that
audcontains the expected audience, and checkexpandnbfagainst the current time. - Check that
substarts withactor/. The same keys signworker-for-actortokens, which a worker or another Substrate component uses when acting for an actor. Those havesubworker-for-actor/<atespace>/<name>and their claims underworker-for-actor.ate.dev, and a relying party that wants the actor itself must reject them.
When a token names a kid the relying party has not seen, it should refetch
the key set: a new key is published before it signs anything. Rate-limit key
set fetches instead of fetching on every unrecognized kid, so invalid tokens
cannot overload ate-idp-server.
The token
Header
| Field | Value |
|---|---|
typ | JWT |
alg | The signing key’s algorithm: ES256, or RS256 |
kid | The signing key’s ID. Treat it as an opaque string that matches one key in the key set |
Claims
| Claim | Value |
|---|---|
iss | The issuer URL. See The issuer |
sub | actor/<atespace>/<name> |
aud | The audiences the caller requested, always a JSON array |
exp | Expiry, in Unix seconds |
nbf | Five minutes before iat, to allow for clock skew |
iat | Issue time, in Unix seconds |
jti | A random token ID |
actor.ate.dev | An object with the actor’s atespace, actorName, and actorUID |
The caller chooses the lifetime with expiration_seconds, which must be
between 300 and 3600. The RPC also returns expire_time, equal to exp. A
request names between 1 and 16 audiences of up to 512 characters each.
Substrate adds no audience of its own.
The subject names the actor, not one incarnation of it: an actor deleted and
recreated under the same name gets the same sub. A relying party that has to
tell the two apart can bind to the actorUID in actor.ate.dev, which
differs. Atespace and actor names are each up to 63 characters, so sub can
be up to 133 bytes long.
Example
A decoded token, with an example key ID and UID:
{
"typ": "JWT",
"alg": "ES256",
"kid": "9XGqai5pBy8t4HsHIxcu68X4_e4fOg7MYyKYMHc7WPA"
}
{
"iss": "https://idp.ate-system.svc",
"sub": "actor/team-a/agent-1",
"aud": ["https://api.example.com"],
"exp": 1760401800,
"nbf": 1760399700,
"iat": 1760400000,
"jti": "MZ4RHTOXLJ6GSVNQ2YAKE7WB3C",
"actor.ate.dev": {
"atespace": "team-a",
"actorName": "agent-1",
"actorUID": "4f9c3a1e-8d2b-4c77-9e0a-6b5f1d2c3e4a"
}
}
The issuer
The issuer is https:// followed by a host, with no path, query, or
fragment, for example https://idp.ate-system.svc or
https://cluster-a.idp.example.com. A port is allowed. ate-api-server puts it
in iss, and ate-idp-server serves the discovery document and key set at
fixed paths under it.
The installer sets it through the actorJWT.issuer setting
(ACTOR_JWT_ISSUER, or --actor-jwt-issuer; see
operator-install.md). It defaults to
https://idp.<namespace>.svc, the in-cluster URL of ate-idp-server. The
installer gives the same value to ate-api-server, through the
ate-api-server-envvars ConfigMap, and to ate-idp-server, as its --issuer
flag, so the token and the discovery document cannot name different issuers.
A changed issuer reaches both on the next ate-setup deploy ate-system.
Tokens signed before a change keep the old iss until they expire, at most
an hour later. A relying party that trusts only the new issuer rejects them
for that time.
Discovery document and key set
ate-idp-server answers GET and HEAD on two paths, both as
application/json. The discovery document, at
<issuer>/.well-known/openid-configuration:
{
"issuer": "https://idp.ate-system.svc",
"jwks_uri": "https://idp.ate-system.svc/openid/v1/jwks",
"response_types_supported": ["id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["ES256"],
"claims_supported": ["iss", "sub", "aud", "exp", "nbf", "iat", "jti", "actor.ate.dev", "worker-for-actor.ate.dev"]
}
id_token_signing_alg_values_supported lists each algorithm in the signing
pool. There is no authorization_endpoint, because the issuer has no login
flow.
The key set, at <issuer>/openid/v1/jwks, is {"keys": [...]} with one
entry per key in the pool, sorted by kid. Each entry has kty, kid,
use (sig), alg, and the public key: crv (P-256), x, and y for
an ES256 key, or n and e for an RS256 key. The set includes keys that are
not signing, so tokens signed by an older key keep verifying during a
rotation.
Both documents are built from the signing pool on every request. ate-idp-server rereads the pool at most once a minute.
Reaching the issuer
ate-idp-server runs in the system namespace behind the idp Service on port
443. It serves HTTPS with a certificate from the
servicedns.podcert.ate.dev/identity signer, which names
idp.<namespace>.svc. A verifier inside the cluster trusts that certificate
through the signer’s ClusterTrustBundle, projected the way
ate-controller.yaml mounts it.
A relying party outside the cluster cannot resolve a Service name. To serve
one, put an Ingress or load balancer with a publicly trusted WebPKI
certificate on a host you own, route /.well-known/openid-configuration and
/openid/v1/jwks to the idp Service over HTTPS, and set the issuer to
https://<that host>. The installer does not create the Ingress, the DNS
record, or the certificate. actor-jwt-federation.md
walks through it, and through setting up a relying party to trust the issuer.
Signing keys and rotation
The signing keys live in the actor-id-jwt-pool Secret. One key signs at a
time, and the key set publishes all of them.
The installer creates the pool with one ES256 key. Set actorJWT.algorithm
(ACTOR_JWT_ALGORITHM) to RS256 for a 4096-bit RSA key instead, for
relying parties that don’t support ES256; the setting only affects a pool the
installer creates. A key’s ID defaults to the base64url SHA-256 of its public
key’s PKIX encoding, without padding. Keys created with an explicit ID keep
it.
Rotate with the kubectl ate admin commands, against the pool Secret
(--name actor-id-jwt-pool --secret-namespace ate-system):
add-jwt-keyadds a key that does not sign yet. ate-idp-server publishes it once the updated Secret reaches its pods and it rereads the pool.activate-jwt-key --key-id <new>makes it the signing key, once relying parties have refetched the key set.remove-jwt-key --key-id <old>removes the old key, once every token it signed has expired.
list-jwt-keys shows each key’s ID and algorithm and marks the one that
signs. add-jwt-key takes --alg and --key-id like make-jwt-pool.
actor-jwt-key-rotation.md gives the timings for each step and what to do about a compromised key.
Known limitations
- No revocation. A token stays valid until
exp, up to an hour after it was minted, even if its actor is deleted. Removing a key from the pool stops every token that key signed, for every actor, once relying parties refetch the key set. - Tokens are bearer tokens. Whoever receives one can present it to any
relying party that accepts its audience until it expires. Atespace editors
write the egress policy, so they choose both the audience and the host that
receives the token. An upstream that echoes request headers hands the token
back to the actor. A relying party should check
suboractor.ate.devas well asaud. - No cloud token exchange. Cloud token exchange endpoints take the JWT as a request parameter, and the gateway only replaces headers, so Substrate can’t yet trade an actor JWT for cloud credentials (#1661).
- A recreated actor can get a stale UID. The gateway caches tokens by actor
name. For up to two thirds of a token’s lifetime after an actor is recreated,
the gateway can inject a token whose
actorUIDnames the old actor (#2208). - Rotation is manual. Keys don’t expire, and nothing rotates them on a schedule. An operator runs the steps under Signing keys and rotation.