Skip to content
Talk to our solutions team

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.

CarriesHeaderNames
The credentialAuthorization: Bearer <token>the caller
The credential (API key)Authorization: ApiKey <id>.<secret>the caller — only iam.svc resolves this form
TenancyX-Customer, X-Product, X-Env, X-Tenantthe 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”.

name: reporting
host: 0.0.0.0
port: "5060"
jwks:
issuers:
iam: "https://iam.internal:5030"
KeyTypeDefaultMeaning
jwks.issuers.<iss>map of stringnoneBase URL fetched for tokens carrying that iss. For this block the key is literally iam.
jwks.timeoutduration3sDeadline for a cold or rotation JWKS fetch. A non-positive or unparseable value falls back to the default.
jwks.disabledboolfalseAccept 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.

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.json
X-Customer: acme
X-Product: forge
X-Env: prod
X-Tenant: tenant-a

The key set returned contains that tenant’s keys only. Resolution at the receiver is by (iss, tenant, kid):

  1. Read tenant and kid from the token — for PASETO both come from the unverified payload and footer, and are authenticated by the signature a step later.
  2. Look up the base URL for iss in jwks.issuers.
  3. Rebuild the four CPET headers from the tenant claim and fetch that tenant’s key set.
  4. Select the entry whose kid matches; kty is OKP, crv is Ed25519, x is 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 setBehaviour
under 5 minutesserved from cache, no network call
5 minutes to 24 hoursserved immediately, refreshed in the background
over 24 hoursblocking 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.

Every step below is mandatory. A chassis service gets steps 1–5 from the framework’s authenticated route wrapper; anything else implements them.

  1. Detect the format. A leading v4.public. is PASETO — kid is 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 to EdDSA and kid read from the header; a JWT with no kid is a hard error.

  2. Resolve the key by (iss, tenant, kid) as above.

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

  4. Check exp and nbf. iat is not enforced anywhere. Treat a token with no exp as invalid.

  5. Pin the tenant.

    Request CPET headersToken tenantResult
    absentanythe verified claim populates the request’s tenancy
    presentequal to the header CPETproceed
    presentanything elsereject

    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.

  6. Check type before reading scope — see below.

  7. Check aud yourself if you care about it. Nothing in the platform verify path validates the audience claim.

ClaimUse it for
subThe subject. User tokens: the identity row id in the realm’s entity. Service tokens: the service identity.
tenantThe caller’s CPET. Already pinned against the request in step 5.
realmWhich identity entity authenticated. Defaults to users. Absent on service tokens.
typeuser, service or mfa_pending. Reject mfa_pending on every surface.
rolesAuthority for a user token.
scopeAuthority for a service token only — a list of CPET grants.
actPresent only on an impersonated token. Narrows authority; never widens it.
customWhatever 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 sid and no aal. 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.

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

The platform authorization model is a set of CPET permissions per identity, each {customer, product, environment, tenant} with * as a wildcard.

PredicateQuestion it answers
accessDoes any permission cover the target CPET?
grantDoes 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.

VocabularyValuesRead by
Route gatesadmin, superadmin (case-insensitive)simple route-level guards; admin passes for either, superadmin is strict
Position ladderplatform_admin, customer_admin, product_admin, env_admin, tenant_admin, tenant_user, tenant_viewer, tenant_customthe 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: reporting
port: "5060"
jwks:
issuers:
iam: "https://iam.internal:5030"

2. A caller obtains a token from the block:

Terminal window
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' \
-d '{"identity":"[email protected]","password":"..."}'
{
"token": "v4.public.eyJpc3MiOiJpYW0i...",
"refresh_token": "01JB0K3XZ7Q2M4Y.9pQ...",
"expires_in": 900,
"user": { "id": "U01JB0K3XZ7Q2M4Y", "email": "[email protected]" }
}

3. The caller presents it to the reporting service, with the CPET of the data being read:

GET /reports/usage HTTP/1.1
Host: reporting.internal:5060
Authorization: Bearer v4.public.eyJpc3MiOiJpYW0i...
X-Customer: acme
X-Product: forge
X-Env: prod
X-Tenant: tenant-a

4. 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:

Terminal window
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.

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.1
Authorization: ApiKey 01JB0K3XZ7Q2M4Y.9pQ...
X-Customer: acme
X-Product: forge
X-Env: prod
X-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.

SymptomCauseFix
401 with a plain-text body Unauthorized, no JSON, no codeThe framework’s token middleware rejected the credential before any handler ranRead 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 errorA JWT library was pointed at a PASETO tokenUse a PASETO-aware verifier, or the framework wrapper
401 on a token that decodes fineTenant pin — the tenant claim does not equal the request CPETSend the CPET the token was minted for
Anonymous instead of unauthorizedThe Authorization scheme was not matched exactly (bearer, or a double space)Bearer and ApiKey with one trailing space
Expired tokens accepted foreverThe signature-only verify entry point, with no clock check of your ownUse the full verify path
A rotated-out key still verifiesThe receiver’s stale-key fallback during an issuer outageExpected; 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.

  • Declare jwks.issuers.iam; never ship jwks.disabled: true.
  • Verify signature and exp/nbf.
  • Pin the token’s tenant to the request CPET, with no operator exception.
  • Reject type: "mfa_pending" everywhere.
  • Read scope only when type is service.
  • Carry act into 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.
  • 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 act means and what it narrows.
  • Operating — boot configuration and key custody.
  • API reference — every route, request and response.