Token kinds
Stralos uses two JWT token kinds signed with shared EdDSA (Ed25519) key material.stralos-api-global and stralos-api-login both mint user tokens; only stralos-api-global mints service tokens:
Both kinds carry a
kid in the JWS header that identifies the signing key in the JWKS.
User token claims
User tokens are minted bystralos-api-global after regional OTP or legacy OIDC authentication, and by stralos-api-login after central login, refresh, or organisation switching. Both services retain the stralos-api-global issuer claim so the existing service verifiers accept the shared token contract. Current browser sessions return through the central login hand-off. The regional shell’s retained /auth/callback route can still redeem a legacy ceremony that was already in progress.
scope claim. Verifiers reject any user-typed token that does.
Contractor company claims
stralos-api-login adds two optional claims for contractor companies. Every product API that enforces billing (Activities, Assistant, Documents, Estimate, Global, Rates, and Scheduler) parses both from user tokens and exposes them on the request auth context.
via is optional, but when present every part must be well formed: org and link must be UUIDs and name must be 1 to 200 characters. A malformed via fails verification of the whole token. Verifiers do not drop it, because a token without via would pass as one of the company’s own people and skip the per-request link check.
When a token carries via, the shared billing guard (registerBillingAccessGuard in @stralos-co-nz/api-common) reads the LINK#<link> row and the contractor company’s own ORG#<org> row from the billing entitlements table on every request:
- The guard returns
403 contractor_access_endedwhen the link row is missing, is notactive, points at a different company or contractor, or the contractor company is no longer on thecontractorplan. It returns the same code when the contractor company’s billing is blocked. - When the contractor company’s billing is read-only, writes return
402 contractor_billing_read_only. Reads continue. - When the billing table cannot be read, the guard returns
503 billing_unavailable. Each API’s IAM policy must therefore grantdynamodb:GetItemonLINK#*keys as well asORG#*.
covered) takes its access mode from the covering contractor company’s entitlement, one hop only. A cover that cannot be followed fails closed as read-only.
The billing claim is optional. When present, it encodes the organisation’s current billing access state. parseBillingAccessClaim decodes it; an absent or unparseable value is treated as no billing restriction.
Billing access guard
When a user token carries abilling claim that indicates restricted product access, the Global API rejects most requests before they reach the route handler. Two endpoints remain available during a billing block so an organisation administrator can resolve the subscription:
GET /api/global/org/billing-seat-usage— returns the current occupied seat count. Administrators use this to verify occupancy before updating the subscription.GET /api/global/org/organisations— returns the caller’s active organisation list. Required for the organisation switcher to continue working.
stralos-api-global performs an authoritative billing check and determines that the stored claim is stale, it refreshes the claim so the next token issued carries the correct access state. This prevents a user from being permanently locked out because of a cached claim that predates a subscription change.
Billing claim
User tokens may carry an optionalbilling claim encoding the organisation’s subscription access state at the time of minting. Services that enforce billing access parse this claim from the JWT and attach it to the request auth context.
Because the claim reflects state at mint time, it can become stale within the token’s lifetime. The billing access guard checks the billing entitlements table on every protected user request. If the authoritative result differs from the JWT claim, the guard returns 401 billing_updated before the route handler runs, so the client refreshes its token and retries the request once.
Tokens minted before billing enforcement was enabled carry no billing claim. The guard skips the comparison for an absent claim and applies the authoritative result.
An optional billing claim may be embedded in user tokens by the issuing service. Receiving
services that use parseBillingAccessClaim parse this claim and expose it as billing on the
request AuthContext. The claim is absent for tokens minted before billing-claim support was added;
services treat a missing billing field on AuthContext as no billing access information
available.
User access token lifetime
User access tokens are configured to 900 seconds (15 minutes) in all deployed environments — production, test, and development. This alignsstralos-api-global with stralos-api-login so tokens from both services share the same lifetime.
ACCESS_TOKEN_TTL_SECONDS accepts a whole number from 60 to 3,600. The service validates this value when it mints a user token; an invalid value makes that mint fail. Terraform exposes the same setting as access_token_ttl_seconds and validates it during planning. Local .env files can set ACCESS_TOKEN_TTL_SECONDS directly.
Service token claims
Service tokens are minted for internal service-to-service calls. Local development usesPOST /api/global/internal/service-tokens; deployed callers directly invoke their dedicated mint-for-<caller> Lambda alias. The mint is not exposed through the deployed public API Gateway.
role claim. A null org is only valid when the platform scope is present.
Signing keys and JWKS
stralos-api-global and stralos-api-login load the same private EdDSA key material for user-token issuance. stralos-api-global is also the service-token minter and publishes the JWKS used by the runtime services. The remaining service APIs hold no signing material and verify only.
Public keys are served at:
kid; when stored key material omits one, the service derives an RFC 7638 thumbprint.
Signing key resolution (api-global)
Resolution order at startup:AUTH_SIGNING_KEY_JWKenvironment variable (JSON, single JWK or array).AUTH_SIGNING_KEY_SSM_PARAMETER— loads from AWS SSM Parameter Store with decryption. Whenauth_signing_key_ssm_parameteris configured, the Global Terraform stack manages the SecureString object and sets this variable on the Lambda; the post-apply finaliser writes the generated Ed25519 JWK.- Local file
AUTH_SIGNING_KEY_FILE(default.auth-signing-keys.json) — generated on first run and persisted for stablekidacross dev restarts.
JWKS caching in consumers
Runtime services andstralos-admin-api’s three remote key sets (user-token, service-token, and report) use the shared createJwksResolver, a rotation-safe resolver with keep-last-good semantics. stralos-admin-api previously used jose’s createRemoteJWKSet and its per-kid negative cache.
Consuming services fetch the JWKS from stralos-api-global and cache the key set in memory, refreshing it proactively when the cached set is older than five minutes. When a token arrives with a kid that is not in the cached set (a possible signer rotation), the resolver triggers one refresh attempt. The refresh uses a global 30-second cooldown shared across all kids: at most one outbound fetch is in flight at a time, and any concurrent callers join the running fetch rather than issuing their own. This bounds the refetch rate to one request per 30 seconds per service instance regardless of the number of distinct unrecognised kids presented. The cooldown advances on failed attempts as well as successful ones, so a burst of requests carrying unrecognised kid values does not trigger a corresponding burst of fetches through the shared NAT gateway.
A per-kid negative cache was used in earlier implementations but replaced with this single global clock (stralos-api-activities and stralos-api-assistant ported it from the scheduler; stralos-api-rates, stralos-api-documents and stralos-api-estimate adopted it in the 2026-10-03 security review). Because kid is attacker-controlled, the per-kid approach let an attacker send many distinct fabricated kid values to force repeated unauthenticated refetches (amplification); the global cooldown bounds that to one refetch per window.
If the refresh fails (network error, WAF block, non-2xx response such as a 4xx or 5xx, timeout), the resolver keeps the last successfully fetched key set and continues verifying tokens against it. Only a cold start with no key set loaded fails closed: the failed refresh is propagated as 401. This keep-last-good behaviour means a transient JWKS outage or a rate-limited edge does not cause estate-wide 401 errors once the cache ages out, for tokens signed with keys that were already cached.
A genuine key rotation is still picked up promptly. Keys seeded at startup via initialJwks do not count as a refetch attempt, so cold-start rotation resolves immediately on the first unknown-kid request. A rotated kid is recognised after at most one cooldown window (30 seconds) of the first miss on the new kid, once the new key appears at the JWKS endpoint.
Refresh failure handling (scheduler)
The scheduler JWKS resolver keeps the last-good key set when a proactive age-based refresh fails. A refresh failure (network error, timeout, or a non-200 response such as a WAF 403) logs a warning and continues serving the previously cached key set. Only a cold start with no key set loaded at all fails closed and returns 401.
This means a transient WAF block of the shared NAT IPs does not cause an authentication outage for an already-warmed scheduler instance. The key set is refreshed at most once per cooldown window whether the trigger is age-based or a kid miss.
EdDSA-only verification (M1b.2)
Global, Estimate, Activities, Documents, and Rates completed the M1b.2 transition by removing their shared-secret branches and authentication rollout flags. Scheduler was EdDSA-only from its first org-secure implementation. The Assistant API follows the same M1b.2 contract, with no HS256 fallback orAUTH_ENFORCE log-only toggle. All seven
runtime services now:
- accept only tokens signed with
alg: EdDSA; - verify the signature through the Global API JWKS;
- enforce the strict claim schema for the permitted token type — Global, Estimate, Activities, Documents, Rates, and the Assistant accept user or authorised service tokens, while Scheduler accepts user tokens only and rejects service tokens; and
- return
401without trying another algorithm when verification fails.
Allowlist and scope clamping (service tokens)
Each internal service caller has an allowlist entry defining its permitted audience targets and scopes. The mint clamps every request to that allowlist — a caller cannot request audience or scope values outside its entry. Platform-scoped callers setorgMode: "platform" and must pass scope: ["platform"] with no org. Tenant-scoped callers must pass a valid org UUID and cannot request the platform scope.
See Service token authentication for the transports and current caller matrix.
OTP verify and OIDC exchange response
A successfulPOST /api/global/auth/otp/verify or POST /api/global/auth/oidc/exchange (IDP-M1) returns the same shape: accessToken, refreshToken, user, and a memberships array added in Org-users M2.
The OIDC exchange is the server-side authorisation-code flow with the regional identity provider. See IDP-M1 — OIDC exchange for the full contract.
From Org-users M2, a successful POST /api/global/auth/otp/verify returns a memberships array alongside the standard accessToken, refreshToken, and user fields:
organisationName is optional for compatibility with sessions and API deployments that predate name labelling. The first regional access token is minted for the default-selected organisation (see Org-users M2 for selection rules). A current central hand-off does not include this array, and a regional refresh token cannot use the central switch endpoint.
The POST /api/login/auth/refresh and POST /api/login/auth/switch-org responses do not include memberships; they return only accessToken, refreshToken, and user.
Billing access claim
User tokens may carry an optionalbilling claim. When present, services parse it with parseBillingAccessClaim and attach the resulting BillingAccess value to the request’s auth context. Services that enforce billing entitlements call registerBillingAccessGuard at startup to install the enforcement hook.
Stale-claim refresh (Rates API)
Thebilling claim is embedded in the token at mint time and does not change until the user refreshes or re-authenticates. A claim becomes stale when the organisation’s subscription state changes after the token was issued — for example, a seat count change or a billing event that alters entitlements.
The Rates API performs an authoritative check after verifying the token. When the embedded billing claim does not agree with the current authoritative state, the service refreshes the claim from the authoritative source before evaluating the guard. The request then proceeds under the refreshed entitlement rather than the stale JWT-embedded value.
This means a billing entitlement change takes effect at the next request rather than at the next token refresh. Callers do not need to take any action — the refresh is transparent.
A missing billing claim on a user token is treated as no entitlement restriction. Service token callers bypass the billing guard entirely.
Session mutation routing
The shell sends central-session mutations tostralos-api-login:
Regional environments proxy
/api/login/* to the login service on the same origin. Local shell development uses the login API at port 3400. These requests use a dedicated session-auth base and do not honour the regional API base override. The shell retired its regional OIDC callback, so no web client calls POST /api/global/auth/oidc/exchange; a regional refresh token from that route is not accepted by these central routes.
Server-held active organisation
The session in DynamoDB carries the active organisation UUID. Clients never supply it —POST /api/login/auth/refresh always carries the held organisation forward, and POST /api/login/auth/switch-org is the only way to change it. A client-supplied organisationId on a refresh body is rejected.
Sessions are stamped with a model version (SESSION_MODEL_VERSION, currently 3). Any session on a prior version is refused with stale on the next refresh, prompting re-login. The version was bumped from 2 to 3 at IDP-M7a to introduce the sessionKind field; all v2 sessions are invalidated on their next refresh.
Session kinds (IDP-M7a)
Every session row carries an explicitsessionKind field. Two kinds are defined:
The two kinds are always read together with the organisation column (
SessionOrgContext). An identity session stores an explicit null for activeOrganisationId; an org session stores a non-null UUID. A v3+ row that is missing sessionKind entirely is treated as malformed.
POST /api/login/auth/refresh rejects an identity session with 403 identity_session_cannot_refresh before consuming the token, so the caller still holds a usable credential. POST /api/login/auth/logout and POST /api/login/auth/switch-org accept a central identity session.
Identity sessions are minted by the login service. The login service owns the current refresh, logout, and organisation-switch contracts. It does not yet expose sign-out-everywhere.
Session generation and sign-out everywhere (IDP-M4c)
The regional IDP-M4c design applies to the retainedstralos-api-global session store. The admin user record owns a monotone sessionGeneration. Runtime user projections receive that value through user.updated events, and each regional refresh-token family snapshots it as familyGeneration.
The legacy regional refresh and switch routes compare the regional session family’s generation with the current runtime user projection before minting. A family whose snapshot is older than the current projection cannot rotate or switch organisation.
POST /api/global/auth/sessions/revoke-all validates a regional refresh-token session without consuming it and enqueues
a signed user.session_revocation_requested report. The admin API increments sessionGeneration
and emits the authoritative user.updated event to the user’s regions. The endpoint returns 202
before that asynchronous propagation completes.
Access tokens do not carry a session-generation claim, so this regional check does not revoke them; they remain usable until their configured TTL of at most one hour, or until another independent control rejects them.
Central login sessions use a separate table and pepper. Migration 0031 grants the stralos_api_login role read-only access to users.session_generation. Login refresh and organisation-switch operations compare the family’s stamped generation with the current value. Families minted before this grant carry the -1 legacy sentinel and are refused on their next rotation. A central refresh token cannot authenticate to the Global API’s retained revoke-all route. POST /api/login/auth/logout deletes only the presented central session.
For sign-out-everywhere, stralos-api-login forwards a signed, body-bound command to stralos-admin-api. The login role cannot update the admin counter directly. Admin runs the same revokeAllSessions transaction as the runtime-report path: it increments sessionGeneration, writes the audit record, and enqueues user.updated to the user’s sticky region set. A successful browser request receives 202 {}; an unavailable or refused relay fails. Regional propagation remains asynchronous. See Admin console API — relay-authenticated session revocation.

