Actor JWTs
An actor does not store its own identity token: the egress gateway mints an actor JWT and puts it in a header that an egress rule names.
Your actor calls a service that you run, and the service must know which actor is calling. The usual answer is a secret inside the workload. An actor is a bad place for a secret, because its memory goes into every snapshot and any editor can copy it. Substrate gives the actor an identity that the actor does not store. The actor can still read the token when a service returns it.
When you finish this section, you can write the egress rule that attaches an actor JWT and list the claims in the token. You can also say what a service must check before it trusts one.
Our runs show two such rules at work. One is the rule that kagent writes for each session. The other is our own rule, which sent a token to a service that returns its request headers, so we could decode the token. The limits at the end come from the documentation at v1.0.0, and we say which of them we saw.
The gateway asks, the actor does not
Actor JWT: a signed token, minted by ate-api-server, that names one actor and one or more audiences.
No API lets an actor request its own token. The token appears only on outbound requests, and only where an egress rule asks for it.
The steps are these:
- The actor sends a plain request to a host that its egress policy allows.
- The egress gateway finds the rule for that host and sees the effect
actorJwt. - The gateway calls
MintActorJwtonate-api-server, with the audiences and the lifetime from the rule. - The gateway writes the token into the header that the rule names, and sends the request on.
Only the egress gateway is allowed to call MintActorJwt. A global owner is refused, as access policies per atespace explains.
The rule that kagent writes
kagent needs this mechanism for its own traffic. Each session is an actor, and the harness in the actor reports to the kagent controller. This is the first rule of the egress policy that kagent wrote for one session in our run.
rules:
- http:
effects:
replaceHeaders:
- actorJwt:
audiences:
- kagent-runtime
expirationSeconds: "300"
header: authorization
prefix: 'Bearer '
hostnames:
- kagent-controller.kagent.svc.cluster.local
ports:
numbers:
- 8083Read it from the bottom up. The rule matches plain HTTP to one host on port 8083. For a matching request, the gateway replaces the authorization header with Bearer and a fresh token. The token is for the audience kagent-runtime, and it lives 300 seconds, the shortest lifetime that the API accepts.
The log of the egress gateway shows the result. The harness called the controller, the request carried an authorization header, and the controller answered 200.
2026-11-12T10:19:50.777573Z info request request.id=64 gateway=default/default listener=listener1 route=default/route0 endpoint=kagent-controller.kagent.svc.cluster.local:8083 src.addr=10.244.0.23:59184 http.method=POST http.host=kagent-controller.kagent.svc.cluster.local http.path=/kagent.runtime.v1.TaskStoreService/SettleTask http.version=HTTP/2.0 http.status=200 grpc.status=0 protocol=http ate.actor.name=session-01a1cf43-d760-747a-b907-5e0640126e4e ate.actor.uid=d64e21f4-05eb-49a3-a81e-69ca2d893b06 ate.atespace=kagent …This rule is on the http kind, so the token crossed the cluster network without TLS between the gateway and the controller. Use the https kind for any service outside your cluster. Credential injection covers the other effect of replaceHeaders, which reads a secret.
What the token says
We wrote a rule like the one of kagent for an actor named jw2 in the atespace lab-v. The audience was lab-echo, the lifetime was 600 seconds, and the host was a service in the cluster that returns its request headers. The actor called that service, and we decoded the token that came back. The listing shows the header and the claims, and it leaves out the signature.
…
header: {
"typ": "JWT",
"alg": "ES256",
"kid": "E38XLIprTZzZkY7RinXF7sPyw-HxHT-ysnVHFvvTCAU"
}
claims: {
"iss": "https://idp.ate-system.svc",
"sub": "actor/lab-v/jw2",
"aud": [
"lab-echo"
],
"exp": 1794488570,
"nbf": 1794487670,
"iat": 1794487970,
"jti": "VLZDMDBJ2UA3ME2Z5M62BADKQK",
"actor.ate.dev": {
"atespace": "lab-v",
"actorName": "jw2",
"actorUID": "93e670a4-3215-41e5-be33-4dffaca75d42"
}
}
…| Claim | Value | In our token |
|---|---|---|
iss | the issuer URL of the install | https://idp.ate-system.svc |
sub | actor/<atespace>/<name> | actor/lab-v/jw2 |
aud | the audiences from the rule, always an array | ["lab-echo"] |
exp | the issue time plus expirationSeconds, which must be 300 to 3600 | iat plus 600 seconds |
nbf | five minutes before iat | iat less 300 seconds |
iat | the issue time | present |
jti | a random id | present |
actor.ate.dev | an object with atespace, actorName and actorUID | all three fields |
The header named the algorithm ES256. Its kid was equal to the one key in the key set that the issuer published. The code also allows RS256.
We saw four more things in that run, once each, on the x86 server guest:
- The gateway uses a token again. Two requests in a row carried the same token, with the same
jti. A request after a suspend and a resume of the actor carried it too. Sojtiis not a request id, and a suspend does not end a token. - The actor must send the header. The effect replaces a header and does not add one. A request that the actor sent without an
authorizationheader reached the service with none. Our other requests sentBearer placeholder, and the gateway replaced it. - The actor can get the token. The service returned its request headers, so the actor read its own token in the response. Send a token only to a service that does not return it.
- One claim was absent. The discovery document lists a claim named
worker-for-actor.ate.dev. Our token did not have it.
Two details change how you use the token:
subis a name. An actor that you delete and create again with the same name gets the samesub. Bind toactorUIDif the difference matters.- Substrate adds no audience. The rule decides
aud, and the person who writes the rule also decides which host receives the token.
Create the policy before the actor asks for the name. In our first try, the actor asked for the host before any policy existed. We created the policy, and 4.5 seconds later the name still did not resolve. In the second try the policy came first, and the name resolved at once. The DNS relay describes one more slow update like this one.
What the service must check
A service that receives the token is a relying party in OpenID Connect terms. ate-idp-server publishes the discovery document at /.well-known/openid-configuration under the issuer, and the keys at /openid/v1/jwks. Our kagent install set the issuer to https://idp.ate-system.svc.
The service must do five checks:
issequals the issuer that you configured.- The signature verifies with the key from the key set that has the
kidof the token. audcontains the audience that the service expects.expandnbffit the current time.substarts withactor/.
Do not skip the last check. The same keys sign a second kind of token, with a sub that starts with worker-for-actor/. Substrate components use that kind to move snapshots. A service that accepts it treats a worker as the actor.
The default issuer is a Service name, so only a verifier inside the cluster can reach it. For a verifier outside, you must publish the two paths on a host that you own and set the issuer to that host. The installer does not create the Ingress, the DNS record, or the certificate.
Limits
The documentation lists these limits. We saw the second one in our run, and we tested none of the others:
- No revocation. A token is valid until
exp, also after you delete the actor. Keep the lifetime at 300 seconds unless a service needs more. - It is a bearer token. A service that returns its request headers in the response gives the token to the actor. Our header service did that, and we saw the same effect with an injected credential in our egress tests.
- A new actor with an old name. After you create an actor again, the gateway can send a token with the
actorUIDof the old one. The window is up to two thirds of the token lifetime (issue 2208). - No exchange for cloud credentials. Cloud token services want the JWT in the request body, and the gateway replaces only headers (issue 1661).
- Key rotation is manual. Keys do not expire. You add, activate and remove keys with
kubectl ate admin.
Three checks come in the next edition. One checks the signature with the published key, as a service must. One rotates a key. One sets up federation with a second issuer or a cloud provider.
Our position
Use the actor JWT for calls from an actor to services that you control. It removes a secret from the actor, and the 300 second lifetime bounds the damage of a leak. Do not treat the audience as the whole access decision. Every atespace editor can write a rule that sends a token with any audience to any host. So give each service its own audience, and make each service refuse every other one. Each service must also decide which actors in sub it serves.
Agent Substrate 1.0.0 · b34fbee · 2026-11-05 · Edition 2026.11