Skip to content
Talk to our solutions team

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.

Credentialtype claimFormatIssued byDefault lifetime
Access tokenuserPASETO v4.publicevery login flow, /auth/refresh, impersonation, session exchange15 min
Service tokenservicePASETO v4.publicPOST /auth/token (API-key exchange)15 min
MFA-pending tokenmfa_pendingPASETO v4.publicpassword login when the identity has TOTP enrolled5 min
Refresh tokenopaque <ulid>.<secret>stateless tenants, alongside the access token30 days
Session referenceopaque 32 random bytes, base64urlmanaged-session tenants, instead of access + refreshidle / 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.

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.

Every claim the block emits, on any token type:

ClaimTypePresent onMeaning
issstringallIssuing service. The literal iam in the shipped wiring.
substringallThe subject. User tokens: the identity row id. Service tokens from POST /auth/token: apikey:<api-key row id>.
tenantstringallThe 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.
realmstringuser, mfa_pendingThe realm that authenticated. Defaults to users for flows that authenticate against the base entity. Not emitted on service tokens.
typestringalluser | service | mfa_pending. The principal discriminator — see below.
iatnumberallIssue time, Unix seconds UTC.
nbfnumberallNot-before, Unix seconds UTC. Equal to iat at mint. Enforced.
expnumberallExpiry, Unix seconds UTC. Enforced.
rolesarray of stringsuser, when non-emptyRole 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.
actobjectimpersonation mints only{sub, realm, mode} naming the operator behind an impersonated token. mode is readonly or readwrite.
audstringservice tokens, when ?audience= was passedThe intended recipient service.
scopearray of stringsservice tokens, when the key carries grantsCPET grants, each customer:product:env:tenant with * as a per-segment wildcard.
customanyuserWhatever 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
}

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.

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.

token:
expiry: 30m
claims:
plan: gold
features: [billing, reports]
claims_from:
department: properties.department
org: orgid

claims 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.

  • 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 the superadmin role, derived from the reserved realm.
  • No sid. An exchanged token does not name the session it came from.
TokenLifetimeHow to change it
Access token15 mintoken.expiry on the tenant’s composed token policy (a Go duration, e.g. "30m", "24h")
Token from a session exchange10 minnot configurable — the override ignores token.expiry
MFA-pending token5 minnot configurable
Service token15 minnot configurable through YAML
Refresh token30 daysnot 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.

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:

  1. External source — the tenant’s jwt.signing.key property. A value starting -----BEGIN is the literal PKCS#8 Ed25519 private-key PEM (development); anything else names a secret in Vault holding that PEM (production).
  2. The tenant’s own jwt_signing_key table — the row marked active.
  3. 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.

Tierkid
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 databasea ULID, so database order matches the rotation timeline
GET /.well-known/jwks.json

Serves 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 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.

One shared verify core routes on the token’s leading segment:

PrefixHandling
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 elseLegacy 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 alg is not EdDSA (including none), and any token whose key id resolves to nothing.

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 claimsexp, iat and nbf. A token whose iat is in the future is rejected.
  • Tenant pin — the token’s tenant claim 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.

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.

To endDo thisEffect
One device’s refresh chainPOST /auth/logout with the refresh tokenThe whole rotation family is deleted. Idempotent, always 200.
A stolen refresh tokennothing — automaticThe replay deletes the family and returns 401 token_reuse.
A managed sessionthe mesh-internal revoke, by control-plane row idThe next exchange returns 401; revocation latency is the phantom token’s remaining TTL.
A compromised signing keyrotate at the source, restart, delete refresh rowsTokens already signed keep verifying until they expire.
One access tokennot possibleA 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”.

The tenant session: block accepts two keys that look like they select cryptography:

session:
preset: fedramp # standard | hipaa | fedramp
crypto_suite: v3 # v4 | v3

Both 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.