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

# Service token authentication

> Inter-service token minting, verification, and the caller allowlist

`stralos-api-global` is the sole issuer of service tokens. It signs short-lived EdDSA tokens whose audiences, scopes, and organisation context are limited by a static caller allowlist. Browser clients never receive service tokens.

## Mint transports

### Deployed environments

The deployed mint is a distinct Lambda handler that is not attached to the public API Gateway. Each authorised caller has a dedicated `mint-for-<caller>` Lambda alias and an IAM policy scoped to that alias. The handler maps the alias qualifier to the caller identity before applying the allowlist.

### Local development

Local callers use:

```http theme={null}
POST /api/global/internal/service-tokens
x-service-mint-secret: <caller-secret>
```

The request body can contain `org`, `aud`, and `scope`. `api-global` maps the presented secret to a caller using `SERVICE_MINT_SECRETS` and compares secrets in constant time. The route is mounted only when local signing keys and at least one caller secret are configured.

## Caller allowlist

| Caller | Target services (`aud`) | Scopes | Org mode |
| - | - | - | - |
| `ci-journey` | `stralos-api-global`, `stralos-api-rates`, `stralos-admin-api` | `global:config-read`, `rates:read`, `admin:probe-read` | Tenant |
| `documents-worker` | `stralos-api-rates`, `stralos-api-estimate` | `rates:read`, `estimate:read` | Tenant |
| `estimate-portal` | `stralos-api-documents` | `documents:portal-read` | Tenant |
| `seed-platform` | `stralos-api-global`, `stralos-api-rates` | `platform` | Platform |
| `pricing-materialise` | `stralos-api-global`, `stralos-api-rates`, `stralos-api-estimate` | `global:config-read`, `rates:read`, `estimate:recompute` | Tenant |
| `global-lifecycle` | `stralos-api-estimate` | `lifecycle:transition` | Tenant |
| `estimate-lifecycle` | `stralos-api-global` | `lifecycle:transition` | Tenant |
| `api-scheduler` | `stralos-api-global` | `projects:read` | Tenant |
| `estimate-reprice` | `stralos-api-rates`, `stralos-api-global` | `rates:reprice-read`, `global:reprice-read` | Tenant |
| `estimate-rates` | `stralos-api-rates` | `rates:estimate-rates` | Tenant |
| `estimate-name-sync` | `stralos-api-global` | `global:name-sync-read` | Tenant |
| `estimate-notify` | `stralos-api-global` | `global:user-contact-read`, `global:notifications-write`, `global:project-approver-add` | Tenant |
| `estimate-office-history` | `stralos-api-global` | `global:office-history-read` | Tenant |
| `assistant-offboarding` | `stralos-api-global` | `global:org-status-read` | Tenant |
| `estimate-offboarding` | `stralos-api-global` | `global:org-status-read` | Tenant |
| `rates-offboarding` | `stralos-api-global` | `global:org-status-read` | Tenant |
| `activities-offboarding` | `stralos-api-global` | `global:org-status-read` | Tenant |
| `scheduler-offboarding` | `stralos-api-global` | `global:org-status-read` | Tenant |
| `documents-offboarding` | `stralos-api-global` | `global:org-status-read` | Tenant |
| `billing-offboarding` | `stralos-api-global` | `global:org-status-read` | Tenant |

A tenant caller must provide a valid organisation UUID and cannot request the `platform` scope. A platform caller must omit `org` and include the `platform` scope. A caller may request a subset of its allowed audiences and scopes; omitting either uses its full allowance.

Unknown callers receive `401`. An authenticated caller requesting authority outside its allowlist receives `403`.

### Caller notes

* **`documents-worker`** — the area-jobs worker in `stralos-api-documents`. Mints a token per unit of work scoped to the document's organisation for the context reads that precede AI processing (rate items from `api-rates`, work items from `api-estimate`). The token is cached per org and re-minted near expiry. Minting uses the estate's dual transport: in deployed environments (`SERVICE_TOKEN_TRANSPORT=lambda`), the worker invokes the `api-global-mint:mint-for-documents-worker` Lambda alias via IAM — no shared secret. In local development, it posts to the loopback mint route using `SERVICE_MINT_SECRET`. Neither transport has a default; missing configuration throws `ServiceMintNotConfiguredError` before any context read attempt, so a misconfigured worker fails with the configuration error rather than reading its peers unauthenticated. A configured mint that fails degrades: the context read proceeds without a token, the peer rejects it, and the area run gets an empty context — the pipeline does not stop because a sibling is down.
* **`ci-journey`** — the release journey gate. Makes authenticated reads against `api-global` to verify the estate responds, and makes one probe read against `stralos-admin-api` (`GET /api/admin/service/probe`) to verify the admin surface is reachable. The `admin:probe-read` scope is held by this caller alone; no other allowlist entry can reach the admin API. The probe returns only deploy-time configuration and touches no tenant, operator, or catalogue data. The caller makes no reads against `stralos-api-rates`; its only former api-rates read (`GET /api/rates/project-rate-overrides`) and that route are both retired. The caller is read-only and audience-clamped.
* **`global-lifecycle`** — `api-global`'s lifecycle orchestrator. Drives project-lifecycle state transitions (accept, reject, reopen, archive, unarchive, admin set status) on the estimate side for one tenant org per unit of work.
* **`estimate-lifecycle`** — `api-estimate`'s lifecycle orchestrator. Drives the project-side transitions (submit tender, revise tender) on the global side for one tenant org per unit of work.
* **`api-scheduler`** — the crew-scheduling service's sole outbound S2S read (crew-scheduling M0). Reads project statuses from `api-global` for a single tenant org per unit of work. Audience and scope are intentionally minimal: `stralos-api-global` and `projects:read` only.
* **`estimate-reprice`**: `api-estimate`'s `ProjectRatesChanged` consumer, which re-prices a project when no user is present. It mints this token for the event's organisation and makes the same reads a synchronous reprice makes, directly against `api-global` and through `api-rates`, which forwards the bearer. On `api-global` the token is confined to five read routes: `GET /api/global/organisation-settings`, `GET /api/global/projects/:id/location-routes`, `POST /api/global/rate-references/resolve-batch` (a batch read carried as a POST), `GET /api/global/locations/:id`, and `GET /api/global/parties/:id`. Any other route returns `403 reprice_read_only` before a handler runs. The location-routes read sets the project-work GUC to the path's project ID, so the token can see that one project without a project membership.
* **`estimate-rates`**: `api-estimate`'s caller for estimate-scoped project rates (PRD 210 §9.2). It mints this token with no user present for the service-only [estimate-rates routes](https://help.stralos.app/api/rates.md#estimate-rates) in `api-rates`: stamping a project's rate rows with its estimate at creation and backfill (`POST /api/rates/estimate-rates/backfill`), moving the project's default estimate when the primary changes (`PUT /api/rates/estimate-rates/defaults`), copying one estimate's project rates to another at duplication or saved-version creation (`POST /api/rates/estimate-rates/copy`), restoring a saved version's rates (`POST /api/rates/estimate-rates/restore`), and discarding an abandoned copy's rates (`POST /api/rates/estimate-rates/discard`). The token is audience-clamped to `stralos-api-rates` and never reaches `api-global`.
* **`estimate-notify`**: `api-estimate`'s notification caller, used where no user is present (the variation email outbox and the notification flusher) and for tender approvals. It mints this token for the organisation and may make three calls on `api-global`, each needing its own scope: read one member's email address and name (`GET /api/global/service/organisations/:orgId/users/:userId/contact` and the matching `HEAD`, `global:user-contact-read`), write one in-app notification (`POST /api/global/service/organisations/:orgId/notifications`, `global:notifications-write`), and add a tender's chosen approver to the project (`POST /api/global/service/organisations/:orgId/projects/:projectId/approvers`, `global:project-approver-add`). The token is audience-clamped to `stralos-api-global`; any other route returns `403 estimate_notify_only` before a handler runs. See [Service-only endpoints](#service-only-endpoints) and [Notifications](https://help.stralos.app/internal/notifications.md).
* **`estimate-name-sync`**: `api-estimate`'s `PartyChanged`/`LocationChanged` event consumer and daily name reconcile. Both have no user context, so their api-global reads — the organisation's project ids and `POST /api/global/rate-references/resolve-batch` for current supplier and location names — carry a token minted for this caller. `api-global` confines the token to exactly those two routes; any other route returns `403 name_sync_read_only`. The daily reconcile also uses an `estimate-reprice` token (separately minted) for the reference fill's api-rates read.
* **`estimate-office-history`**: `api-estimate`'s Tender check office price comparison. The comparison reads every tender from the checked project's office, whatever the running person can open, so it cannot use their session token. It mints this token for the estimate's organisation and reads one thing from `api-global`: the project's office and the other projects run from that office. The token is audience-clamped to `stralos-api-global` and confined to `GET /api/global/service/organisations/:orgId/projects/:projectId/office-projects` (and the matching `HEAD`). Any other route returns `403 office_history_read_only` before a handler runs. See [Service-only endpoints](#service-only-endpoints) and [Tender check](https://help.stralos.app/internal/tender-check.md#office-price-comparison).
* **`*-offboarding` callers** (`assistant-offboarding`, `estimate-offboarding`, `rates-offboarding`, `activities-offboarding`, `scheduler-offboarding`, `documents-offboarding`, `billing-offboarding`): the weekly retention Lambda in each of those services. Before it hard-deletes an organisation's data, it mints a token for that one organisation and reads the organisation's status. The token may only call `GET /api/global/service/organisation-status`; any other route returns `403 org_status_read_only` before a handler runs. See [Data retention](https://help.stralos.app/internal/data-retention.md#organisation-status-guard).

## Service-only endpoints

These routes are not part of the product API. Each is gated by a specific service-token scope.

### GET /api/global/service/organisation-status

Returns the status of the organisation the service token was minted for. The organisation is always the token's own `org`, never a parameter, so a token minted for one organisation cannot ask about another.

* **Required scope:** `global:org-status-read` (the `*-offboarding` callers). Any other caller receives `403`.
* **200:** `{ "organisationId": "<uuid>", "status": "<status>" }`
* **404:** the organisation has no row in `organisations`.

The route answers for a `disabled` organisation exactly as for an active one, because that is its purpose: the mint does not check organisation status, the billing access guard skips service tokens, and the query does not filter on status. Callers purge only on an explicit `disabled`; any other status, a `404`, or an error keeps the data.

### GET /api/global/service/organisations/:orgId/users/:userId/contact

Returns the email address and name of one member of the token's organisation. `api-estimate`'s variation email outbox uses it to address a notice when no user session is present.

* **Required scope:** `global:user-contact-read` (the `estimate-notify` caller). Any other caller receives `403 forbidden`.
* **Path constraint:** `:orgId` must be the token's own organisation. A mismatch receives `403 organisation_mismatch`, so the caller cannot ask about another organisation's members.
* **200:** `{ "email": "<address>", "name": "<name>" }`, with `Cache-Control: no-store`.
* **404 `not_found`:** the user ID is not a UUID, the person is not an invited or active member of the organisation (including a member of another organisation or a person who has left), or the user is disabled. Every case returns the same body, so the response reveals nothing about who exists outside the organisation.

A user ID that is not a UUID, such as the `local` placeholder `api-estimate` can record as a creator in local development, returns `404` without a database read.

### POST /api/global/service/organisations/:orgId/notifications

Writes one in-app notification, which api-global also emails. api-estimate sends the kind and typed `details`; api-global writes the title and body.

* **Required scope:** `global:notifications-write` (the `estimate-notify` caller). Any other caller receives `403 forbidden`; an `:orgId` other than the token's own receives `403 organisation_mismatch`.
* The project-access kinds are raised by api-global itself and are refused with `400 kind_not_allowed`.
* `404 recipient_not_found` or `project_not_found`; `409 recipient_cannot_open_project` when the recipient is not on the project team.
* Idempotent on the recipient and `sourceKey`: `201 { id, created: true }` the first time, `200 { id, created: false }` after.

See [Notifications](https://help.stralos.app/internal/notifications.md#service-routes).

### POST /api/global/service/organisations/:orgId/projects/:projectId/approvers

Puts a tender's chosen approver on the project team as a member when they are not on it, and answers `{ userId, added }`.

* **Required scope:** `global:project-approver-add` (the `estimate-notify` caller). Any other caller receives `403 forbidden`; an `:orgId` other than the token's own receives `403 organisation_mismatch`.
* The estimator's own token must ride `x-forwarded-user` (`403 forwarded_user_required`), and the estimator must be on the project (`403 project_access_required`).
* Business people only, never contractors (`404 person_not_found`). `404 project_not_found` for a project outside the business.

### GET /api/global/service/organisations/:orgId/projects/:projectId/office-projects

Returns a project's office and the other projects run from that office. `api-estimate`'s Tender check uses it to compare an estimate with the office's recent tenders.

* **Required scope:** `global:office-history-read` (the `estimate-office-history` caller). Any other caller receives `403 forbidden`.
* **Path constraint:** `:orgId` must be the token's own organisation. A mismatch receives `403 organisation_mismatch`.
* **200:** `{ "organisationId", "projectId", "office": { "locationId", "name" } | null, "projects": [{ "projectId", "name", "jobNumber", "status", "archivedFrom" }], "truncated" }`, with `Cache-Control: no-store`. `projects` lists the office's other projects, newest first, up to 2,000, with `truncated` set when there were more. It excludes the requested project and `lead` projects, and includes archived projects. Entries carry names, job numbers and statuses only: no client and no address.
* **`office: null`:** the project has no office, and `projects` is empty.
* **404 `project_not_found`:** the project is not one of the organisation's.

The route reads through the `office_peer_projects(p_org, p_project)` `SECURITY DEFINER` function (migration `0093`). It refuses (`42501`) a `p_org` that does not match the transaction's tenant GUC, filters on its arguments on both sides of the office join, and is still bound by `tenant_isolation` under FORCE RLS, so it cannot return another organisation's projects.

## Claims and verification

Service tokens contain:

| Claim | Value |
| - | - |
| `typ` | `"service"` |
| `sub` | `service:<caller-name>` |
| `aud` | Non-empty array of target service names |
| `scope` | Non-empty array of granted scopes |
| `org` | Organisation UUID, or `null` for a platform caller |
| `iss` | `"stralos-api-global"` |
| `exp` | Issued-at plus 600 seconds |

Receiving services verify the EdDSA signature and issuer, require the token's audience to include their configured service name, and validate the claims. Service tokens must not contain `role`. A null organisation is valid only with the `platform` scope.

Successful verification exposes the service identity, organisation context, and scopes on the request auth context. What a verified service token may then do is confined per receiving service: `stralos-api-documents` uses a per-route allowlist and `stralos-api-estimate` a mutation guard, both described below, and `stralos-api-rates` has its own [service login confinement](https://help.stralos.app/internal/rates-service.md#service-login-confinement). Elsewhere, route handlers remain responsible for enforcing the scope appropriate to an operation.

### Per-route scope enforcement

Passing the audience check is not sufficient for a service token to reach a route. `stralos-api-documents` declares a list of `serviceRoutes` — the exact set of route patterns and HTTP methods that a service token may use, each paired with the exact scope required for that route. A service token is refused with `403` and code `service_scope_required` on any route that is not in that list, or where the token's scope does not include the declared scope for that route.

This means the audience check alone does not determine what a service token may do. A token minted for a service with multiple scopes (such as `documents:portal-read`) cannot reach routes that require a different scope, even though the audience admits it to the service.

The `stralos-api-documents` service declares these service routes:

| Method | Route | Required scope |
| - | - | - |
| `GET` | `/api/documents/:id` | `documents:portal-read` |
| `GET` | `/api/documents/:id/download-url` | `documents:portal-read` |
| `PUT` | `/api/documents/usages/:module/:entityType/:entityId` | `documents:usages-write` |

All other routes in the Documents service return `403` for any service token, regardless of audience or scope.

## Service token mutation guard

Service tokens are denied every mutating HTTP method (`POST`, `PUT`, `PATCH`, `DELETE`) by default. A route that a service legitimately drives must opt in by declaring `config.serviceMutationScope` with the single exact scope required. The auth plugin then checks that the incoming service token's `scope` array includes that value.

A service token that does not carry the required scope, or that reaches a mutating route with no `config.serviceMutationScope` set, receives `403` with `code: "forbidden"` before the route handler runs.

Read requests (`GET`, `HEAD`, `OPTIONS`) are unchanged. Existing RLS and project-scope gates continue to confine what a service token can read.

The only service-driven writes into `stralos-api-estimate` are the lifecycle transitions driven by `global-lifecycle`. Those routes declare `config.serviceMutationScope: "lifecycle:transition"`, and the `global-lifecycle` caller's token carries `scope: ["lifecycle:transition"]`.

| Caller | Scope required for writes | Routes |
| - | - | - |
| `global-lifecycle` | `lifecycle:transition` | Estimate lifecycle transition routes |

All other callers that target `stralos-api-estimate` (`documents-worker`, `pricing-materialise`, `estimate-reprice`, `estimate-rates`, `estimate-name-sync`) make read-only calls and carry no mutation scope.

## Signing keys and rotation

`api-global` resolves private signing key material in this order:

1. `AUTH_SIGNING_KEY_JWK` — an inline JWK or ordered JWK array.
2. `AUTH_SIGNING_KEY_SSM_PARAMETER` — an encrypted AWS SSM parameter.
3. `AUTH_SIGNING_KEY_FILE` — a local generate-or-load file, defaulting to `.auth-signing-keys.json`.

The first key signs new tokens. Public keys are published through the [JWKS endpoint](https://help.stralos.app/api/authentication.md#jwks-endpoint). See [JWKS caching in consumers](https://help.stralos.app/internal/auth-token-architecture.md#jwks-caching-in-consumers) for the full resolver behaviour, including the global refetch cooldown and keep-last-good policy.


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