> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Auth token architecture

> EdDSA token model and JWKS rotation in Stralos

This page covers the internal mechanics of Stralos token issuance and verification. It is relevant to engineers working on service APIs or the auth infrastructure.

## 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:

| Kind | `typ` claim | `aud` | Subject format | TTL |
| - | - | - | - | - |
| User | `"user"` | `"stralos-api"` | UUID (user ID) | 15 minutes (900 s) in all deployed environments |
| Service | `"service"` | Array of target service names | `service:<caller>` | 10 minutes |

Both kinds carry a `kid` in the JWS header that identifies the signing key in the JWKS.

## User token claims

User tokens are minted by `stralos-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.

```json theme={null}
{
  "typ": "user",
  "sub": "<user-uuid>",
  "email": "<user-email>",
  "org": "<organisation-uuid>",
  "role": "admin | member",
  "billing": { ... },
  "plan": "standard | contractor",
  "via": {
    "org": "<contractor-company-uuid>",
    "link": "<company-link-uuid>",
    "name": "<contractor-company-name>"
  },
  "aud": "stralos-api",
  "iss": "stralos-api-global",
  "iat": <unix>,
  "exp": <unix>
}
```

User tokens must not carry a `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.

| Claim | Value | When present |
| - | - | - |
| `plan` | `"standard"` or `"contractor"` | The billing plan of the company the token is for. Absent when billing could not be read at mint time, and on tokens minted before contractor companies existed. Services treat an absent `plan` as `"standard"`. |
| `via` | `{ org, link, name }` | Present only when the person reached this company through a company link as a contractor person. `org` is the contractor company's UUID, `link` is the company link UUID, and `name` is the contractor company's name at mint time. |

`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_ended` when the link row is missing, is not `active`, points at a different company or contractor, or the contractor company is no longer on the `contractor` plan. 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 grant `dynamodb:GetItem` on `LINK#*` keys as well as `ORG#*`.

A company covered by a contractor company (billing status `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 a `billing` 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.

Both endpoints are classified as recovery requests by the billing access guard. All other authenticated endpoints return an error until the billing claim is refreshed with a valid subscription state.

When `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 optional `billing` 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 aligns `stralos-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 uses `POST /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.

```json theme={null}
{
  "typ": "service",
  "sub": "service:<caller-name>",
  "org": "<organisation-uuid> | null",
  "scope": ["<scope1>", ...],
  "aud": ["stralos-api-<target>", ...],
  "iss": "stralos-api-global",
  "iat": <unix>,
  "exp": <unix>
}
```

Service tokens must not carry a `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:

```
GET /api/global/.well-known/jwks.json
```

The key store is an ordered list. Index 0 is the current signer. Retained keys stay in the JWKS until all tokens they signed have expired. Each key has a stable `kid`; when stored key material omits one, the service derives an RFC 7638 thumbprint.

### Signing key resolution (api-global)

Resolution order at startup:

1. `AUTH_SIGNING_KEY_JWK` environment variable (JSON, single JWK or array).
2. `AUTH_SIGNING_KEY_SSM_PARAMETER` — loads from AWS SSM Parameter Store with decryption. When `auth_signing_key_ssm_parameter` is 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.
3. Local file `AUTH_SIGNING_KEY_FILE` (default `.auth-signing-keys.json`) — generated on first run and persisted for stable `kid` across dev restarts.

### JWKS caching in consumers

Runtime services and `stralos-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 or
`AUTH_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 `401` without 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 set `orgMode: "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](https://help.stralos.app/internal/service-token-auth.md) for the transports and current caller matrix.

## OTP verify and OIDC exchange response

A successful `POST /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](https://help.stralos.app/internal/idp-m1-oidc-exchange.md) 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:

```json theme={null}
{
  "accessToken": "...",
  "refreshToken": "...",
  "user": { "id": "...", "email": "...", "name": "...", "organisationId": "...", "role": "..." },
  "memberships": [
    {
      "organisationId": "<uuid>",
      "organisationName": "<display name>",
      "role": "admin | member",
      "status": "active"
    }
  ]
}
```

The historical regional shell used this array to populate an organisation picker for users who belonged to more than one organisation. `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](https://help.stralos.app/internal/org-security.md#org-users-m2-mint-from-membership-and-switch-org) 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 optional `billing` 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)

The `billing` 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 to `stralos-api-login`:

| Operation | Endpoint |
| - | - |
| Refresh the access token | `POST /api/login/auth/refresh` |
| Switch organisation | `POST /api/login/auth/switch-org` |
| Log out this session | `POST /api/login/auth/logout` |

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 explicit `sessionKind` field. Two kinds are defined:

| Kind | Meaning |
| - | - |
| `"org"` | Standard session. Holds an active organisation UUID. Every access token minted from it is scoped to that organisation. |
| `"identity"` | Authenticated, no organisation selected. Used to span the post-login organisation chooser. May `switch-org` or `logout`; may **not** refresh. |

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 retained `stralos-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](https://help.stralos.app/internal/admin-console.md#relay-authenticated-session-revocation-prd-05-§8-4).

## OTP and session storage

OTP challenges and refresh-token sessions are stored in DynamoDB. The login service handles the central session table for the current refresh, organisation-switch, and logout paths. Api-global retains OTP, legacy OIDC exchange, regional session compatibility routes, and regional sign-out-everywhere during the auth-route migration. The central and regional session stores use separate tables and separate peppers, so their refresh tokens are not interchangeable. Sessions rotate on refresh and invalidate their own session family on reuse detection.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.