Service-to-Service Auth: Client Credentials vs. On-Behalf-Of Delegation
Table of Contents
1. Summary
This page is the decision guide for the identity group of the pattern language. It answers one question: which identity does this call carry? The answer is a pattern: Client Credentials, Token Relay, Token Exchange, or, for work a schedule starts, Scheduled Work Acting as a Tenant. The pattern pages hold each mechanism's failure handling, pitfalls and sources. This page holds the questions that choose between them, the ORE Studio idioms, and the rule against reaching around a service boundary.
A service that needs data another service owns has exactly two legitimate
ways to authenticate that call — mint its own service identity (the
client credentials grant) or forward the calling user's own token (the
on-behalf-of, OBO, delegation flow) — never both blended into one call,
and never a shortcut around either by reaching into the other service's
repository/tables directly. (When no inbound caller exists, neither flow fits. An orchestrator that must
act as several identities in turn uses Token Exchange, as
internal actor impersonation
records, and work a schedule starts uses Scheduled Work Acting as a Tenant.) Which of the two flows below to use is
decided by one
question: is the data being read scoped to the current caller (tenant,
party, permissions) or is it scoped to the service itself (background
work, bootstrap, data the service is always allowed to see regardless of
who asked)? ores.nats.service::nats_client implements both as one type
(with_delegation layers OBO on top of an existing, already-authenticated
client) — see Anatomy of a Service for the mechanics and
Detect and correct reversed FX spot quotes during market data import for a
worked OBO example.
2. Detail
2.1. The two flows, named properly
These aren't ORE Studio inventions — they're the standard OAuth2/OIDC service-to-service flows, and the industry names are worth using because they carry the right connotations:
- Client Credentials Grant — the service authenticates as itself, using its own credentials (here: its database account, exchanged for a JWT via IAM). The resulting identity has no tenant, no party, and no end-user permissions — it is the service. Correct for: startup bootstrap, a background feed/job the service runs autonomously, or any read of data that is genuinely global/service-owned rather than caller-owned. Minting at startup specifically has an ordering constraint — see Service Bootstrap Phases: the target subject (which, for a service authenticating against itself, is one of the service's own subjects) must already be subscribed.
- On-Behalf-Of (OBO) delegation — the service forwards the original caller's token (extracted from the inbound request) to the downstream call, so the downstream service resolves tenant/party/permissions as if the original caller had called it directly. Correct for: any read or write of data that is tenant- or party-scoped, where "what can I see" must match "what the calling user is allowed to see" — not what the relaying service happens to be allowed to see.
Mixing them up is the actual failure mode to watch for: using client
credentials for tenant-scoped data silently returns nothing (RLS
correctly filters to the service's own — empty — tenant context, not an
error), which looks exactly like "the data doesn't exist" rather than
"I asked with the wrong identity". This is precisely what happened before
this pattern was applied to the FX quote-convention check (see the worked
example below) — the service-account fetch of currency_pair data
silently came back empty because currency_pair is party-scoped and the
service account has no party, not because the data was actually missing.
2.2. When to use which
- Ask "if the roles were reversed and this were an HTTP API, would this be a request the user makes, or a request the service makes on a schedule/at startup?" User-shaped request → OBO. Service-shaped request → client credentials.
- Ask "does the target table have a
tenant_id=/=party_idcolumn whose value should vary depending on who's asking?" Yes → OBO. No (or it's genuinely system/global reference data) → client credentials, and no delegation is needed at all. - When in doubt, check how the table itself is scoped (RLS policy,
tenant_id=/=party_idcolumns) rather than guessing from the calling code's context — the failure mode above is exactly what happens when that check is skipped.
2.3. How to use it
Both flows share one starting point: a client-credentials nats_client,
built once (typically in the service's application.cpp=/=main.cpp) from
the service's own database account credentials:
ores::nats::service::nats_client svc_nats( nats, ores::iam::client::make_service_token_provider( nats, cfg.database.user, cfg.database.password()));
Used as-is, every call through svc_nats is client-credentials (the
service's own identity). To make one specific call OBO instead, delegate
the current request's caller token onto a copy (with_delegation
returns a new value, it does not mutate svc_nats) before that call:
auto downstream = svc_nats.with_delegation(extract_bearer(msg)); auto reply = downstream.authenticated_request(subject, json);
extract_bearer(msg) pulls the Authorization: Bearer header off the
inbound NATS message the handler is currently processing — i.e. this
only works inside a handler that itself received an authenticated
request; there is no caller token to forward from a background job.
The receiving service's make_request_context understands both: a plain
Authorization header resolves to the client-credentials identity;
an X-Delegated-Authorization header (which with_delegation sets)
resolves to the delegated caller's full identity instead — tenant, party,
and permissions all as that caller, not as the relaying service.
Where to obtain the information being fetched: always through the owning
service's NATS API (its published protocol/=nats_subject=), decoded via
the typed request/response structs in its *.api component — the same
contract any other caller of that service uses, authenticated with
whichever of the two flows fits per the decision above.
2.4. What not to do: direct cross-service repository access
It is tempting, when both services happen to share one physical Postgres
instance, to skip the NATS round-trip entirely and have one service's
core component directly construct another service's repository/mapper
classes against a shared context. Resist this even though it compiles
and — today — even returns correct rows:
- it silently assumes the two services share a database, which is an implementation detail of the current deployment, not a contract either service has agreed to keep;
- it bypasses whatever authorization the owning service's handler would
have enforced (
has_permissionchecks, request-context validation) — the read happens with no authorization boundary at all, tenant-scoped data or not; - it couples the two services' internal schemas directly — a column rename or table split in the owned service silently breaks the borrowing service's build (or, worse, its runtime queries) with no compiler or protocol-level signal;
- it defeats the entire reason these are separate, independently ownable/deployable services rather than one monolith.
If a cross-service read feels awkward via NATS (extra round-trip,
pagination, no convenience client library yet), that awkwardness is a
prompt to add a typed client library for the owned service (see
ores.marketdata.client as the reference pattern), not a reason to reach
around the service boundary.
2.5. Across the HTTP edge, and what storage adds
Everything above is about NATS, but the same question is asked at the HTTP boundary and the answer is the same: a route has a caller, so it acts as that caller. The generated routes do it for their author rather than trusting them to remember – the route templates build
auto delegated = session.with_delegation(req.get_bearer_token().value_or(std::string{}));
so every modelled route relays the caller's identity to the service that serves it, and a route that a hand-written handler backs follows the same rule.
Byte movement is where this becomes visible, because object storage answers on
both interfaces. storage_transfer takes a bearer token as a required
parameter, and the callers that have a caller to forward pass that caller's
token – extract_bearer(msg) off an inbound NATS message, or a token carried in
the request itself. The token is the end user's, not the relaying service's, and
storage authorises against it.
Two consequences follow, and both were learned the hard way:
- The HTTP boundary must verify the tokens the platform actually issues. IAM signs the session tokens (RS256, published through its JWKS); if the edge verifies a different algorithm, every delegated call dies at the boundary on the algorithm rather than on a permission, and the failure looks like an authorisation bug in a service that is behaving correctly.
- Where no caller exists there is nothing to forward. A timer, a scheduled
publish or a startup task acts as the service, with the client-credentials
identity built once from
svc_nats; a workflow step acts as the caller only when the request that dispatched it carried that caller's token.
How the edge verifies is worth stating, because the failure above is easy to reintroduce. The gateway holds no signing key of its own and never mints a token: IAM is the only signer. At startup the service framework fetches IAM's public key from its JWKS endpoint over NATS, and the gateway hands that one verifier to every session. There is deliberately no second, symmetric verifier to fall back to. A shared secret would be a second way to be believed rather than a second way to sign, and since the edge has nothing to sign, it would authenticate nothing legitimate while widening what an attacker who learns the secret can do. A session with no verifier refuses every request rather than serving it, so a failed key fetch is a loud outage instead of a quiet weakening.
A token that travels inside a request is a short-lived credential, not durable
state. It may be relayed through a message; it must not become a stored
credential, and a row, a queue archive or a workflow instance that retains one
past the request is a defect worth reporting. Open question: the ORE import
request carries bearer_token, and the workflow instance is persisted, so
whether that token is stored with it needs checking – the policy above says it
must not be.
By layer, then. The last column names the pattern; its page holds the failure handling and the pitfalls.
| Layer | Acts as | Mechanism | Pattern |
|---|---|---|---|
UI, CLI, ores.shell |
The signed-in user | Session token on every call | Access Token |
| HTTP edge (generated routes) | The caller, always | with_delegation(req.get_bearer_token()) |
Token Relay |
| Domain service handler | The caller, for user-shaped work | Forward the inbound bearer | Token Relay |
| Domain service, own work | Itself | Client credentials built once (svc_nats) |
Client Credentials |
| Workflow step or timer | Itself, unless dispatched with a bearer | Client credentials, or the dispatched token | Client Credentials |
| Step of a scheduled run | The run's tenant and party | A run token exchanged for the run grant | Scheduled Work Acting as a Tenant |
| Orchestrator, internal actor | A specific end user, no client | Self-minted impersonation, last resort | Token Exchange |
3. Patterns used
- Database per Service — the reasons a service must not reach into another's repository.
4. See also
- Service architecture patterns — the pattern language; this page is the decision guide for its identity group.
- Report execution at scale — the run grant and the token exchange that let a scheduled run act as its tenant and party.
- Object Storage — the bucket and key protocol, and the delegated bearer the storage interfaces take.
- Identity and Access — the structure note that orders this cluster, and where to read this page in it.
- Anatomy of a Service — "Calling another service: mint or delegate", the mechanics this doc names and expands on.
- Service-to-Service JWT Delegation — the original delegation design plan.
- ores.iam.client —
make_service_token_providerand session helpers. - Multi-Tenancy Architecture — tenant/party scoping and RLS.
- Detect and correct reversed FX spot quotes during market data import —
worked OBO example:
ores.marketdatareading party-scopedcurrency_pairdata fromores.refdata. - Service Bootstrap Phases — when during startup it is safe to mint a client-credentials token, including the self-referential case (a service minting a token against its own subjects).