Machine-to-Machine Service Clients
Create a client_credentials service client, mint a userless token with the right audience, and call another backend on a user's behalf with X-User-Sub.
Local portal docs
A service client lets one backend call another without a logged-in user. It uses the OAuth client_credentials grant to mint a short-lived, userless token. Every token carries exactly one audience (aud) claim identifying the single resource server it may call — but one client identity can be permitted to request several audiences and pick the target per token (see The audience contract). This is the mechanism behind cross-service calls like Lucky Little Devil registering a brand candidate in megaphone, or megaphone fetching a brokered platform token from connect.
When to use a service client
Use one when a server needs to act on its own behalf, or on a user's behalf from a context where no user token exists — a queue worker, a cron job, a webhook handler, a server-to-server API call.
Do not use one for browser login. User-facing apps use a normal OAuth app client (the authorization-code flow). See Registering OAuth Applications.
How a service client differs from a normal app client:
- Grant — a service client uses
client_credentials; an app client usesauthorization_code+refresh_token. - Who logs in — nobody logs into a service client; it *is* the backend. An app client logs in a user in a browser.
- Token subject — a service token's
subisservice:<serviceName>; an app token'ssubis the user's id. - Redirect URIs — a service client has none; an app client requires them.
- Audience — a service token is scoped to the one resource server it calls; an app token is scoped to the app.
- Created by — service clients are platform-admin-only; app clients are self-serve for any developer.
Creating a service client
Service clients are platform infrastructure, so creation is admin-only. From the developer portal, open Service clients → Create service client (the nav item appears only for platform admins).
You provide:
- Name — a human label.
- Service name — a lowercase slug (e.g.
megaphone,lld-worker). It becomes the token subjectsub = service:<serviceName>and must be unique. - Default audience — the resource server this client's tokens target by default, chosen from the registry (e.g.
service.connect,service.megaphone). It is stamped asaudwhen a token is requested with noresourceparameter. Pick Custom… only for a target not yet in the registry. - Additional audiences — optional. Other registry resource servers this same client may also call. It selects the target per token via the OAuth
resourceparameter (RFC 8707); each token still carries exactly oneaud. Leave empty for a single-target client. - Scopes — constrained to the union of the scopes meaningful across the selected audiences (default + additional). A pure-audience target (one that authorizes on
audalone) contributes no scopes.
On creation the client secret is shown once. Copy it immediately and store it server-side only. Any admin can later rotate the secret, edit the audience/scopes, or delete the client from its detail page; deleting it immediately revokes all of its tokens.
Minting a token
Exchange the client id + secret at the token endpoint using HTTP Basic auth. OAuth endpoints are relative to the Mana API base URL:
Development: https://dev.portal.raum.au/mana
Production: https://www.thecrimsonmarket.com/manacurl -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials \
https://www.thecrimsonmarket.com/mana/oauth/token{
"access_token": "<jwt>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "connect:read",
"aud": "service.connect"
}The token's sub is service:<serviceName> and its aud is the client's default audience. There is no refresh token — mint a fresh one when it expires (cache it until then).
Requesting a specific audience
If the client is permitted more than one audience, choose the target for a given token with the resource parameter (RFC 8707). It must be the default audience or one of the additional audiences, otherwise the request fails with invalid_target.
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials \
-d resource=service.connect \
https://www.thecrimsonmarket.com/mana/oauth/token
# -> aud=service.connectOmit resource to get the default audience. The returned token still carries a single aud.
With the shared SDK
@crimsoncorp/connect-client ships a token provider that mints, caches, and refreshes the token for you:
import { createServiceTokenProvider } from '@crimsoncorp/connect-client';
const getAccessToken = createServiceTokenProvider({
tokenUrl: process.env.PORTAL_TOKEN_URL, // https://www.thecrimsonmarket.com/mana/oauth/token
clientId: process.env.MEGAPHONE_CLIENT_ID,
clientSecret: process.env.MEGAPHONE_CLIENT_SECRET,
scope: 'connect:read connect:write',
});
const token = await getAccessToken(); // cached + refreshed ahead of expiryThe provider forwards its optional audience as the resource parameter, so a multi-audience client creates one provider per target (each has its own cache):
const getConnectToken = createServiceTokenProvider({ ...common, audience: 'service.connect' });
const getMegaphoneToken = createServiceTokenProvider({ ...common, audience: 'service.megaphone' });The audience contract
The audience is what stops a token minted for one service from being replayed at another. Every resource server checks the incoming token's aud against its own id and rejects a mismatch:
- A token with
aud=service.connectis accepted by connect and rejected everywhere else. - The convention for the audience value is
service.<name>(e.g.service.connect,service.megaphone).
Each token carries exactly one aud, so it is accepted by exactly one resource server. A single client, however, may be permitted to request several audiences: it has one default audience plus any number of additional audiences, and picks the target per token with the resource parameter. So a backend that calls two services (like lld → both connect and megaphone) uses one service client with both audiences — no need for a separate client and secret per target. The permitted set is shown on the client's detail page.
A common cause of "401 invalid token audience" is a token whose aud doesn't match the server it's calling — confirm you requested the right resource, and that the target is in the client's permitted audiences on its detail page.
Delegation: acting on a user's behalf
A service token is userless, but a backend often needs to act for a specific user — for example, a worker publishing a clip to *that player's* connected socials. Pass the user's TCM sub in an X-User-Sub header alongside the service token:
Authorization: Bearer <service token, aud=service.connect>
X-User-Sub: <the target user's TCM sub>The resource server honors X-User-Sub only for allowlisted service clients carrying the right scope (for connect, connect:read), and ignores it otherwise — impersonation is a deliberate, restricted capability, not a default. With the SDK:
const connections = await client.listConnectionsForUser(userSub, { platform: 'youtube' });
const { access_token } = await client.getTokenForUser(userSub, connectionId);Worked example: LLD → megaphone
Lucky Little Devil uses one service client to reach two services — it reads a player's social connections from connect and asks megaphone to publish/curate. It is created here (Service clients section) with default audience service.megaphone and additional audience service.connect, scopes connect:read connect:write.
When a game finishes and its Short publishes, LLD registers the clip as a brand-curation candidate in megaphone — server to server, no user present:
- Its candidate worker mints a megaphone-targeted token (
createServiceTokenProvider({ audience: 'service.megaphone' }), cached) and POSTs to megaphone's/api/service/candidateswith:
``text Authorization: Bearer <token, aud=service.megaphone> X-User-Sub: <the clip owner's TCM sub> ``
- megaphone validates the audience, reads
X-User-Subas the publishing identity, and records the candidate. - For connect calls (e.g. listing the player's connections) the same client mints a separate token with
resource=service.connect— a differentaudfrom the same identity.
If megaphone is down the call simply retries on its queue — the service-client contract (mint → call → validate aud) is the same regardless of the payload.
Operational notes
- Treat the client secret like any other server credential: store it server-side, never in the browser, and rotate it on suspected exposure (rotation invalidates the previous secret immediately).
- The audiences and scopes are editable after creation, but changing the default audience or removing an additional one repoints or narrows where the client can call — only do it deliberately.
- Every platform admin can see and manage every service client; the creating admin is recorded for audit.