Tokens
Every token iam.svc signs is a PASETO v4.public token over the tenant’s own Ed25519 key. One
signing function serves every mint path, so there is no per-request and no per-tenant format
negotiation, and nothing on the wire an attacker can downgrade.
What the block issues
Section titled “What the block issues”| Credential | type claim | Format | Issued by | Default lifetime |
|---|---|---|---|---|
| Access token | user | PASETO v4.public | every login flow, /auth/refresh, impersonation, session exchange | 15 min |
| Service token | service | PASETO v4.public | POST /auth/token (API-key exchange) | 15 min |
| MFA-pending token | mfa_pending | PASETO v4.public | password login when the identity has TOTP enrolled | 5 min |
| Refresh token | — | opaque <ulid>.<secret> | stateless tenants, alongside the access token | 30 days |
| Session reference | — | opaque 32 random bytes, base64url | managed-session tenants, instead of access + refresh | idle / absolute timeout |
Refresh tokens and session references are not tokens in the PASETO sense: they carry no claims, are
not signed, and mean nothing outside iam.svc. Only the three PASETO forms are ever presented as
Authorization: Bearer. The session reference belongs to the managed model — see
Sessions.
Wire format
Section titled “Wire format”v4.public.<base64url payload || signature>.<base64url footer>The payload is the claim set as JSON. The footer carries the key id. The Ed25519 signature covers both, so a token whose footer names a key it was not signed with fails verification. There is no header segment and no algorithm field — the version tag is the algorithm.
Claim set
Section titled “Claim set”Every claim the block emits, on any token type:
| Claim | Type | Present on | Meaning |
|---|---|---|---|
iss | string | all | Issuing service. The literal iam in the shipped wiring. |
sub | string | all | The subject. User tokens: the identity row id. Service tokens from POST /auth/token: apikey:<api-key row id>. |
tenant | string | all | The full four-segment CPET (customer:product:env:tenant) the token was minted for. Selects the signing key and is cross-checked against the request headers at every verifier. |
realm | string | user, mfa_pending | The realm that authenticated. Defaults to users for flows that authenticate against the base entity. Not emitted on service tokens. |
type | string | all | user | service | mfa_pending. The principal discriminator — see below. |
iat | number | all | Issue time, Unix seconds UTC. |
nbf | number | all | Not-before, Unix seconds UTC. Equal to iat at mint. Enforced. |
exp | number | all | Expiry, Unix seconds UTC. Enforced. |
roles | array of strings | user, when non-empty | Role slugs from the identity row’s properties.roles. A token minted in the reserved superadmin realm always carries the superadmin marker, unioned with the row’s own roles. |
act | object | impersonation mints only | {sub, realm, mode} naming the operator behind an impersonated token. mode is readonly or readwrite. |
aud | string | service tokens, when ?audience= was passed | The intended recipient service. |
scope | array of strings | service tokens, when the key carries grants | CPET grants, each customer:product:env:tenant with * as a per-segment wildcard. |
| custom | any | user | Whatever the tenant’s token.claims / token.claims_from declare. |
A decoded user access token:
{ "iss": "iam", "sub": "U01JB0K3XZ7Q2M4Y", "tenant": "acme:forge:prod:tenant-a", "realm": "users", "type": "user", "roles": ["tenant_admin"], "iat": 1753600000, "nbf": 1753600000, "exp": 1753600900, "plan": "gold"}A service token, minted on a separate path that shares only the signing machinery:
{ "iss": "iam", "sub": "apikey:01JB0K3XZ7Q2M4YB9Q0S1TCE7V", "type": "service", "tenant": "acme:forge:prod:tenant-a", "aud": "search", "scope": ["acme:forge:prod:*"], "iat": 1753600000, "nbf": 1753600000, "exp": 1753600900}type decides what scope means
Section titled “type decides what scope means”A receiver honours the scope claim as authority only when type is service. A user token
carrying a scope claim gains nothing from it — user authority derives from roles. scope is also
a reserved claim name, so a tenant cannot inject one. The escalation path is closed at both ends.
mfa_pending is a half-authenticated credential: it carries iss, sub, tenant, realm, type
and the three time claims, nothing else. The block’s own middleware rejects it on every protected
route; only POST /auth/mfa/verify accepts it. See MFA.
Reserved claims
Section titled “Reserved claims”iss, sub, tenant, realm, roles, iat, nbf, exp, type, scope, act.
A tenant’s token policy may augment the claim set and never shadow these names. The rule is
enforced twice: the reserved name is refused when the token policy is parsed, and skipped again at
mint. scope is reserved to close the privilege-escalation path above; act is reserved so a tenant
cannot forge audit attribution to a named operator.
Custom claims
Section titled “Custom claims”token: expiry: 30m claims: plan: gold features: [billing, reports] claims_from: department: properties.department org: orgidclaims are static values stamped into every access token. claims_from maps a claim name to a
dotted path on the authenticated identity row; a path that resolves to nothing simply omits the
claim rather than emitting a null. Custom claims apply to user access tokens only — service and
MFA-pending mints do not read the tenant token policy.
Claims that are not there
Section titled “Claims that are not there”- No
aal. The assurance level is recorded on the managed-session row and is readable there, but no claim carries it. Step-up policy cannot be enforced from a token today. - No
is_superadmin. The claim name is still read by legacy receiver code, but no mint path emits it. Operator status travels as thesuperadminrole, derived from the reserved realm. - No
sid. An exchanged token does not name the session it came from.
Lifetimes
Section titled “Lifetimes”| Token | Lifetime | How to change it |
|---|---|---|
| Access token | 15 min | token.expiry on the tenant’s composed token policy (a Go duration, e.g. "30m", "24h") |
| Token from a session exchange | 10 min | not configurable — the override ignores token.expiry |
| MFA-pending token | 5 min | not configurable |
| Service token | 15 min | not configurable through YAML |
| Refresh token | 30 days | not configurable in the shipped wiring |
token.expiry is the only operator-facing lever on token lifetime. The refresh TTL and the iss
value exist as construction options that the production boot path never sets.
Signing keys
Section titled “Signing keys”Key material is per tenant. There is no platform-wide signing key, so compromising one tenant’s key cannot forge a token for another.
Resolution runs in three tiers, first match wins:
- External source — the tenant’s
jwt.signing.keyproperty. A value starting-----BEGINis the literal PKCS#8 Ed25519 private-key PEM (development); anything else names a secret in Vault holding that PEM (production). - The tenant’s own
jwt_signing_keytable — the row marked active. - Generate — a fresh Ed25519 keypair, persisted into the tenant’s database on first use.
overrides: jwt: signing: key: jwt-signing-acme-eu1 # vault secret name (production) # key: | # or the literal PEM (development) # -----BEGIN PRIVATE KEY----- # ...The property lives in the tenant’s own overrides: block, so two tenants share a key only by
deliberately naming the same vault secret. A configured-but-unresolvable external source is a hard
error, never a silent fall-through to generation: naming a vault secret with no vault client
registered fails the mint rather than minting under a key nobody intended.
Key ids
Section titled “Key ids”| Tier | kid |
|---|---|
| External (vault or inline PEM) | RFC 7638 JWK thumbprint of the public key — identical on every replica and across restarts, with no coordination |
| Generated in the tenant database | a ULID, so database order matches the rotation timeline |
GET /.well-known/jwks.jsonServes the requesting tenant’s keys only, with the four CPET headers naming the tenant. Tenant A’s key set never contains tenant B’s keys.
{ "keys": [ { "kid": "Xk9…", "kty": "OKP", "crv": "Ed25519", "x": "<base64url public key>", "alg": "EdDSA", "use": "sig" } ]}The set contains the external key first when one is configured, then every database row — active
and inactive — so tokens signed by a rotated-out key keep verifying until they expire. alg reads
EdDSA even though the tokens are PASETO: it is the same Ed25519 key material either way.
Assembled sets are cached per tenant for 60 seconds. When reassembly fails and a cached set exists, the cached set is served rather than failing the caller. The database read behind assembly is capped at 100 rows, and a malformed row is skipped rather than failing the whole set.
Rotation
Section titled “Rotation”Rotation marks the active row inactive and generates a new active key. Old tokens keep verifying, because the inactive row stays in the table and in the JWKS. Rotation refuses outright for an externally-managed key — the external tier always wins resolution, so a database-rotated key would never sign anything.
An external key is rotated at its source — write the new PEM to the vault secret. The active key is
cached in-process per tenant with no expiry, and nothing in the shipped wiring invalidates that
cache, so the new key takes effect when iam.svc restarts.
Verification
Section titled “Verification”Dual-accept, precisely
Section titled “Dual-accept, precisely”One shared verify core routes on the token’s leading segment:
| Prefix | Handling |
|---|---|
v4.public. | PASETO v4.public. kid from the footer, iss and tenant peeked from the payload, then the signature authenticates payload and footer together. A missing kid footer is a hard error. |
v3.public. | Refused with an explicit error. No ECDSA resolver is wired. |
| anything else | Legacy EdDSA JWT, parsed with the signing method pinned to EdDSA and kid read from the header. A missing kid is a hard error. |
Both accepted formats verify against the same per-tenant Ed25519 key the JWKS publishes, which is why the cutover needed no new key plumbing at relying parties.
What this means concretely:
- Minted today: PASETO v4.public, always, for every token type.
- Still accepted: legacy EdDSA JWTs signed by a key the tenant’s JWKS still publishes.
- Never accepted: PASETO v3, any JWT whose
algis notEdDSA(includingnone), and any token whose key id resolves to nothing.
Verifying in your own service
Section titled “Verifying in your own service”A chassis service declares the issuer and wraps its routes; verification then happens before the handler runs.
jwks: issuers: iam: "https://iam.internal:5030"jwks.issuers.<iss> is a boot-plane key — a sibling of host and port, never nested under
hives:. Only issuers listed here are ever contacted, which is what stops a forged iss claim
turning into an outbound fetch. An absent entry rejects every token from that issuer.
The middleware then applies, in order:
- Signature against the key resolved by
(iss, tenant, kid)from the issuer’s per-tenant JWKS. - Temporal claims —
exp,iatandnbf. A token whoseiatis in the future is rejected. - Tenant pin — the token’s
tenantclaim must equal the request’s CPET headers. With no header key present, the verified claim populates it. There is no superadmin exception on either side: an operator reaches tenant data only by impersonation, whose token is the target user’s and so satisfies the pin by construction. See Impersonation.
The two layers treat a missing exp differently. The block’s own middleware rejects a token with no
exp outright; the chassis relying-party validation treats every time claim as optional and passes a
token that carries none. Every token the block mints carries all three, so this only matters for
what a relying party would accept from a key that is still published.
Key caching on the relying-party side prefers availability over freshness. A cached key is served immediately for 5 minutes, then refreshed in the background while still being served, and is refetched blockingly only past 24 hours. If that refetch fails, a cached key is still honoured — so during an issuer outage a rotated-out key can keep verifying.
Non-chassis consumers: fetch GET /.well-known/jwks.json with the CPET headers, verify the Ed25519
signature over the PASETO v4.public token, check exp and nbf, then authorize on tenant, realm
and roles — see Authorization.
Refresh tokens
Section titled “Refresh tokens”On a stateless tenant, login returns an access token and a refresh token:
<row-ulid>.<base64url(32 random bytes)>The row id locates the row; the second part is the rotating credential. The server stores only
SHA-256(secret) in hex, so a privileged database read cannot recover a usable refresh token. The
hash is fast by design — a refresh token is checked on every refresh cycle, and its input already
carries 256 bits of entropy from a cryptographic source.
Rotation family. Every row carries a family id, seeded from the first row in the chain.
Redeeming a token marks the presented row rotated — a tombstone, not a delete — and issues a new
row in the same family.
Reuse detection. Presenting an already-rotated token deletes the entire family and answers
401 token_reuse. That is the theft signature: marking rather than deleting is precisely what makes
a later replay detectable.
Refresh re-reads the identity. The new access token is minted through the same builder as login, against a fresh read of the identity row in the realm recorded on the refresh row. A deleted identity cannot refresh, a role change takes effect at the next refresh rather than at next login, and a patient-realm session refreshes to a patient-realm token.
POST /auth/refresh is refused with 403 managed_session on a managed-session tenant. Minting there
would hand back a self-contained token the registry knows nothing about: not revocable, not
concurrency-counted, not idle-timed — every property the managed posture exists to provide.
What revocation can and cannot do
Section titled “What revocation can and cannot do”| To end | Do this | Effect |
|---|---|---|
| One device’s refresh chain | POST /auth/logout with the refresh token | The whole rotation family is deleted. Idempotent, always 200. |
| A stolen refresh token | nothing — automatic | The replay deletes the family and returns 401 token_reuse. |
| A managed session | the mesh-internal revoke, by control-plane row id | The next exchange returns 401; revocation latency is the phantom token’s remaining TTL. |
| A compromised signing key | rotate at the source, restart, delete refresh rows | Tokens already signed keep verifying until they expire. |
| One access token | not possible | A self-contained token has no revocation channel. Wait out exp. |
Two limits follow from that table and are worth stating plainly:
- An issued access token cannot be revoked. The only bound on its authority is its lifetime, which is why the default is 15 minutes and why the managed-session exchange overrides it to 10.
- There is no key revocation list. Rotated-out keys stay in the JWKS and keep verifying. Urgent key revocation means rotating the key, dropping refresh tokens, and waiting out the access TTL.
POST /auth/logout deletes refresh-token rows only. It does not touch the session registry and does
not clear the kis_session cookie, so it does not end a managed session, and there is no
public “log out everywhere”.
Crypto suites and compliance presets
Section titled “Crypto suites and compliance presets”The tenant session: block accepts two keys that look like they select cryptography:
session: preset: fedramp # standard | hipaa | fedramp crypto_suite: v3 # v4 | v3Both parse, merge, resolve and store. Neither reaches a signer. The mint hard-codes PASETO
v4.public, and the session exchange hard-codes "v4.public" in its response. A tenant on the
fedramp preset gets exactly the same Ed25519 tokens as every other tenant.
A v3 token could not be used anyway: the shared verify core rejects the v3.public. prefix outright,
because no ECDSA resolver is wired.
What preset does change is the session posture — whether login returns tokens or an opaque
reference, and the idle and absolute timeout defaults. That part is live, and it is documented under
Sessions. Treat crypto_suite as having no effect.
Continue with
Section titled “Continue with”- Sessions — stateless versus managed, the exchange, revocation.
- Authentication — the flows that produce these tokens.
- Authorization — how
roles,scopeandactbecome authority. - Identity — the entities behind
sub,realmandroles. - Operating the block — boot configuration, key custody, failure modes.
- Error codes — every
(status, code)pair. - IAM API reference — request and response shapes for every endpoint.