Verifying tokens in another service
A service that accepts an iam.svc token needs three things: the issuer’s base URL in its own boot
config, the four CPET headers on the request, and a verify path that resolves the key per tenant.
Nothing is pre-shared — no symmetric secret, no pinned public key, no registration step at
iam.svc. A receiving service that can reach the issuer’s JWKS endpoint can verify.
What travels on the wire
Section titled “What travels on the wire”| Carries | Header | Names |
|---|---|---|
| The credential | Authorization: Bearer <token> | the caller |
| The credential (API key) | Authorization: ApiKey <id>.<secret> | the caller — only iam.svc resolves this form |
| Tenancy | X-Customer, X-Product, X-Env, X-Tenant | the resource |
That is the whole contract. The platform sends no identity headers: there is no X-User-ID, no
X-User-Role, no X-Roles. Subject, roles, realm and impersonation all come out of the token and
nowhere else.
The two schemes are prefix-matched with exactly one trailing space. bearer <token> (lower case) or
Bearer <token> (two spaces) is not a credential — the request is treated as anonymous, which
surfaces as “no principal”, not as “bad token”.
Configure the issuer
Section titled “Configure the issuer”name: reportinghost: 0.0.0.0port: "5060"jwks: issuers: iam: "https://iam.internal:5030"| Key | Type | Default | Meaning |
|---|---|---|---|
jwks.issuers.<iss> | map of string | none | Base URL fetched for tokens carrying that iss. For this block the key is literally iam. |
jwks.timeout | duration | 3s | Deadline for a cold or rotation JWKS fetch. A non-positive or unparseable value falls back to the default. |
jwks.disabled | bool | false | Accept tokens with no signature check. Development only. |
jwks.issuers is a boot-plane key — top level in the boot document, a sibling of host and
port, never nested under hives:. It is read from the inline boot document first and only then
from the cluster snapshot.
Only issuers listed here are ever contacted. That is what stops a forged iss claim from turning
into an outbound fetch to an attacker’s host. An absent entry rejects every token from that issuer
with jwks: no configured URL for issuer "iam".
iam.svc is subject to this too. Its own /rest and /admin routes go through the same verify
path, so its boot config must set jwks.issuers.iam to its own externally reachable URL or a token
it just minted 401s on its own entity surface.
How keys reach you
Section titled “How keys reach you”Key material is per tenant. There is no platform-wide signing key, so a receiver cannot cache one key and be done.
GET /.well-known/jwks.jsonX-Customer: acmeX-Product: forgeX-Env: prodX-Tenant: tenant-aThe key set returned contains that tenant’s keys only. Resolution at the receiver is by
(iss, tenant, kid):
- Read
tenantandkidfrom the token — for PASETO both come from the unverified payload and footer, and are authenticated by the signature a step later. - Look up the base URL for
issinjwks.issuers. - Rebuild the four CPET headers from the
tenantclaim and fetch that tenant’s key set. - Select the entry whose
kidmatches;ktyisOKP,crvisEd25519,xis the base64url raw public key.
A tenant key with a single segment — the development default, or a reserved scope such as
superadmin — fills all four headers with that one segment.
The key set is served with Cache-Control: no-store, so it is not HTTP-cacheable. Receivers cache it
themselves:
| Age of the cached set | Behaviour |
|---|---|
| under 5 minutes | served from cache, no network call |
| 5 minutes to 24 hours | served immediately, refreshed in the background |
| over 24 hours | blocking refetch — but a failed refetch still honours the cached key |
The issuer’s key set includes active and inactive keys, so a token signed before a rotation keeps verifying until it expires.
The verification sequence
Section titled “The verification sequence”Every step below is mandatory. A chassis service gets steps 1–5 from the framework’s authenticated route wrapper; anything else implements them.
-
Detect the format. A leading
v4.public.is PASETO —kidis in the footer, not a header.v3.public.is refused outright. Anything else is parsed as a legacy EdDSA JWT with the signing method pinned toEdDSAandkidread from the header; a JWT with nokidis a hard error. -
Resolve the key by
(iss, tenant, kid)as above. -
Verify the Ed25519 signature. For PASETO the signature covers payload and footer together, so a token whose footer names a key it was not signed with fails.
-
Check
expandnbf.iatis not enforced anywhere. Treat a token with noexpas invalid. -
Pin the tenant.
Request CPET headers Token tenantResult absent any the verified claim populates the request’s tenancy present equal to the header CPET proceed present anything else reject There is no superadmin exception at either layer. An operator’s control-plane token is refused on a tenant surface exactly like any other mismatch; the only route from operator to tenant data is impersonation, whose token is the target user’s and so satisfies the pin by construction.
-
Check
typebefore readingscope— see below. -
Check
audyourself if you care about it. Nothing in the platform verify path validates the audience claim.
The identity you get
Section titled “The identity you get”| Claim | Use it for |
|---|---|
sub | The subject. User tokens: the identity row id in the realm’s entity. Service tokens: the service identity. |
tenant | The caller’s CPET. Already pinned against the request in step 5. |
realm | Which identity entity authenticated. Defaults to users. Absent on service tokens. |
type | user, service or mfa_pending. Reject mfa_pending on every surface. |
roles | Authority for a user token. |
scope | Authority for a service token only — a list of CPET grants. |
act | Present only on an impersonated token. Narrows authority; never widens it. |
| custom | Whatever the tenant’s token policy adds. Never a reserved name. |
The platform’s principal builder resolves the subject as userid, else user, else sub. This
block emits only sub, so that is what you get. Roles are accepted as an array of strings, an array
of untyped values, or a comma-separated string.
Two claim-shaped things a receiver does not get:
- No
sidand noaal. A token exchanged from a managed session is indistinguishable from a stateless one. You cannot tell which session it came from, cannot see the assurance level, and cannot enforce step-up from the token. See Sessions. - No revocation signal. The only bound on an access token’s authority is
exp.
type decides what scope means
Section titled “type decides what scope means”Honour scope as authority only when type is service. A user token carrying a scope claim must
gain nothing from it. scope is also a reserved claim name at the issuer, so a tenant cannot inject
one — the escalation path is closed at both ends, and it stays closed only if the receiver keeps its
half.
A service-token scope entry is a four-segment CPET grant with * as a per-segment wildcard:
{ "type": "service", "scope": ["acme:forge:prod:*", "acme:search:*:*"] }Anything that is not exactly four colon-separated segments is silently ignored when the grant list is
parsed. One legacy service token in the platform carries scope: "notify.send" — a capability string,
not a grant — and it parses to no permissions at all.
act must be threaded, not dropped
Section titled “act must be threaded, not dropped”act is an object {sub, realm, mode} naming the operator behind an impersonated token; mode is
readonly or readwrite. It fails closed on purpose: a malformed act still yields an actor
whose unrecognised mode reads as read-only, and only a completely absent act means “not an
impersonation”.
Turning claims into a decision
Section titled “Turning claims into a decision”The platform authorization model is a set of CPET permissions per identity, each
{customer, product, environment, tenant} with * as a wildcard.
| Predicate | Question it answers |
|---|---|
| access | Does any permission cover the target CPET? |
| grant | Does one permission cover an entire proposed grant region? This is the issuance check that stops an API key being minted with reach its issuer lacks. |
There are two disjoint role vocabularies on the wire, and a role recognised by one is invisible to the other.
| Vocabulary | Values | Read by |
|---|---|---|
| Route gates | admin, superadmin (case-insensitive) | simple route-level guards; admin passes for either, superadmin is strict |
| Position ladder | platform_admin, customer_admin, product_admin, env_admin, tenant_admin, tenant_user, tenant_viewer, tenant_custom | the CPET permission builder |
Each ladder role maps to a permission relative to the caller’s own position. An unrecognised role
contributes nothing — the model is deny-by-default, so a typo in a role name is a silent loss of
authority, never a silent grant. A product may define additional roles through a validated overlay;
those names must be namespaced with a : and must not collide with a reserved name.
The superadmin role is derived from the realm at mint, not from the identity row, and is unioned
with the row’s own roles. A tenant cannot write itself into the control plane by editing data. See
Superadmin.
Worked example: a service accepting a token
Section titled “Worked example: a service accepting a token”A reporting service exposes GET /reports/usage and wants it readable by any authenticated caller in
the tenant that owns the data.
1. Declare the issuer in the reporting service’s boot config:
name: reportingport: "5060"jwks: issuers: iam: "https://iam.internal:5030"2. A caller obtains a token from the block:
curl -s https://iam.internal:5030/auth/login \ -H 'Content-Type: application/json' \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: prod' -H 'X-Tenant: tenant-a' \{ "token": "v4.public.eyJpc3MiOiJpYW0i...", "refresh_token": "01JB0K3XZ7Q2M4Y.9pQ...", "expires_in": 900,}3. The caller presents it to the reporting service, with the CPET of the data being read:
GET /reports/usage HTTP/1.1Host: reporting.internal:5060Authorization: Bearer v4.public.eyJpc3MiOiJpYW0i...X-Customer: acmeX-Product: forgeX-Env: prodX-Tenant: tenant-a4. The reporting service resolves the key. It reads iss: "iam" and
tenant: "acme:forge:prod:tenant-a" from the token, looks the issuer up in jwks.issuers, rebuilds
the headers from the tenant claim, and fetches:
curl -s https://iam.internal:5030/.well-known/jwks.json \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: prod' -H 'X-Tenant: tenant-a'{ "keys": [ { "kid": "01JB0J8YV3", "kty": "OKP", "crv": "Ed25519", "x": "3s9K1a...", "alg": "EdDSA", "use": "sig" } ]}5. Verification succeeds and the handler sees:
{ "iss": "iam", "sub": "U01JB0K3XZ7Q2M4Y", "tenant": "acme:forge:prod:tenant-a", "realm": "users", "type": "user", "roles": ["tenant_admin"], "iat": 1753600000, "nbf": 1753600000, "exp": 1753600900}6. The decision. type is user, so scope is irrelevant; roles carries tenant_admin,
which reaches acme:forge:prod:tenant-a; the token’s tenant already equals the request CPET. The
report is served. Had the same token arrived with X-Tenant: tenant-b, step 5 of the sequence would
have rejected it before the handler ran.
On a chassis service, steps 4 and 5 are the framework’s — declaring the issuer and wrapping the route with the authenticated route wrapper is the entire integration, and the handler reads tenancy, subject and roles from the request context. Any other stack implements the verification sequence directly against the JWKS endpoint.
Calling another service from yours
Section titled “Calling another service from yours”A service that needs its own identity — not a user’s — exchanges an API key for a service token:
POST /auth/token?audience=search HTTP/1.1Authorization: ApiKey 01JB0K3XZ7Q2M4Y.9pQ...X-Customer: acmeX-Product: forgeX-Env: prodX-Tenant: tenant-a{ "token": "v4.public...", "token_type": "Bearer", "expires_in": 900 }The result carries type: "service" and the scope grants bound to that key, plus aud when
?audience= was passed. The API key itself never leaves your service. An API key with no scope is
refused with 403 not_a_service_key. See API keys.
Forwarding a user’s token to a downstream service works and preserves the user’s authority exactly — the downstream verifies against the same tenant key set. Forward the four CPET headers with it, or the downstream will populate tenancy from the claim and may not be reading the CPET you intended.
When it does not work
Section titled “When it does not work”| Symptom | Cause | Fix |
|---|---|---|
401 with a plain-text body Unauthorized, no JSON, no code | The framework’s token middleware rejected the credential before any handler ran | Read the service’s logs; the envelope carries nothing |
jwks: no configured URL for issuer "iam" | jwks.issuers.iam missing, or nested under hives: | Move it to the boot plane, top level |
| Verification fails with a segment-count error | A JWT library was pointed at a PASETO token | Use a PASETO-aware verifier, or the framework wrapper |
401 on a token that decodes fine | Tenant pin — the tenant claim does not equal the request CPET | Send the CPET the token was minted for |
| Anonymous instead of unauthorized | The Authorization scheme was not matched exactly (bearer, or a double space) | Bearer and ApiKey with one trailing space |
| Expired tokens accepted forever | The signature-only verify entry point, with no clock check of your own | Use the full verify path |
| A rotated-out key still verifies | The receiver’s stale-key fallback during an issuer outage | Expected; do not use rotation as revocation |
Errors raised by iam.svc itself are JSON {"error", "code"} on the auth surface and
{"error", "code", "details"} with v2.-prefixed codes on the entity surface. Codes are not unique
across surfaces — key on (status, code). The full table is in
Error codes.
Receiver checklist
Section titled “Receiver checklist”- Declare
jwks.issuers.iam; never shipjwks.disabled: true. - Verify signature and
exp/nbf. - Pin the token’s
tenantto the request CPET, with no operator exception. - Reject
type: "mfa_pending"everywhere. - Read
scopeonly whentypeisservice. - Carry
actinto your authorization decision; treat any unrecognised mode as read-only. - Take identity from the token only — never from a request header.
- Name a tenant on every authorization target; an empty tenant segment skips that check.
Continue with
Section titled “Continue with”- Tokens — the claim set, key custody, rotation and lifetimes.
- Sessions — managed sessions and the exchange endpoint.
- API keys — issuing keys and their capabilities.
- Impersonation — what
actmeans and what it narrows. - Operating — boot configuration and key custody.
- API reference — every route, request and response.