Service-to-Service Auth: Client Credentials vs. On-Behalf-Of Delegation
Table of Contents
Summary
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. (A third, narrower pattern —
internal actor impersonation — applies specifically to server-side
orchestrators that must act as several identities in turn with no inbound
caller to delegate from; see that doc for when it, rather than either
flow below, is the right call.) 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.
Detail
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.
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.
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.
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.
See also
- 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).