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_id column 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_id columns) 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_permission checks, 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

4. See also

Emacs 29.3 (Org mode 9.6.15)