Actor JWTs

What an actor JWT contains and how a relying party verifies it.

7 min read ·

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:

  1. Check that the iss claim equals the trusted issuer.
  2. Fetch <issuer>/.well-known/openid-configuration over TLS, verifying the server’s certificate, and check that its issuer equals the same value. See Reaching the issuer for how to trust the certificate.
  3. Fetch the document’s jwks_uri over TLS with the same certificate check, and take the key whose kid matches the token header. Check the signature with that key and its alg.
  4. Check that aud contains the expected audience, and check exp and nbf against the current time.
  5. Check that sub starts with actor/. The same keys sign worker-for-actor tokens, which a worker or another Substrate component uses when acting for an actor. Those have sub worker-for-actor/<atespace>/<name> and their claims under worker-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

FieldValue
typJWT
algThe signing key’s algorithm: ES256, or RS256
kidThe signing key’s ID. Treat it as an opaque string that matches one key in the key set

Claims

ClaimValue
issThe issuer URL. See The issuer
subactor/<atespace>/<name>
audThe audiences the caller requested, always a JSON array
expExpiry, in Unix seconds
nbfFive minutes before iat, to allow for clock skew
iatIssue time, in Unix seconds
jtiA random token ID
actor.ate.devAn 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):

  1. add-jwt-key adds a key that does not sign yet. ate-idp-server publishes it once the updated Secret reaches its pods and it rereads the pool.
  2. activate-jwt-key --key-id <new> makes it the signing key, once relying parties have refetched the key set.
  3. 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 sub or actor.ate.dev as well as aud.
  • 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 actorUID names 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.
Was this page helpful?