Skip to main content

Machine-to-machine (API clients)

For a backend that acts as itself — a nightly job, a service calling another service, a partner's server hitting your API. No user, no session: the OAuth 2.0 client credentials grant, with credentials from an API client you register.

There are two sides, and you may be on one or both:

  • The caller holds a client id and secret and exchanges them for a token.
  • The resource server verifies that token and authorizes by scope.

A machine token and a user token never cross. Every SDK verifies them with separate calls (verifyClient / verify_client vs verify), and a user token presented to a machine route is rejected exactly as a machine token is rejected by a user route.

1. Register the client​

In the console: Applications → Manage → API clients. In code, with a management token whose account holds CLIENT_MANAGE for that organization:

curl -X POST https://sso.yourcompany.com/api/clients \
-H "Authorization: Bearer $MANAGEMENT_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"reporting-job","orgId":42,"applicationId":7,"scopes":["reports.write"]}'
{ "clientId": "client_9f3a…", "clientSecret": "kQ7v…" }
Field
nameRequired. Shown in the client list and the audit trail.
orgIdRequired. The customer the machine acts for — it becomes org_id / tenant_id in every token the client mints, and the usage attribution.
applicationIdOptional, but see below. Binds the client to one application.
scopesOptional; defaults to ["api.default"]. The client can never be granted a scope that is not registered here.

CLIENT_MANAGE on its own is not enough — the caller must also have access to that orgId, so an admin of one tenant cannot mint a client billed to another.

applicationId decides the audience

The aud claim on the issued token depends on it, and aud is what a resource server checks.

  • With applicationId → aud is that application's identifier, the same value your resource server is configured with. Verification passes, and the token is scoped to one application exactly like a user token.
  • Without it → aud is the client id itself, so verification fails unless the resource server sets its audience to that literal client_9f3a… — one audience per client, which does not scale.

If a Latch Vector SDK will verify the token, register the client with an applicationId. Leave it off only when something else consumes the token.

2. Store the secret​

The plaintext secret exists exactly once, in that response. The service keeps only a hash: listing clients never returns it, and there is no endpoint to retrieve or rotate it. If it is lost or leaked, register a new client and swap the configuration.

It is an outbound credential of the calling application — it does not belong in the verification config (issuer / audience), which needs no secret at all. Keep it with your other outbound secrets: environment, a secrets manager, a vault. Never in a URL, a query string, or a log line — the SDKs send it as HTTP Basic.

3. Get a token​

const machine = await sso.clientCredentials(clientId, clientSecret, ['reports.write']);
machine.accessToken; // send as: Authorization: Bearer …
machine.expiresInSeconds; // ~900
machine.scope; // what was actually granted — may be narrower than asked

The same call is clientCredentials(...) in PHP and Java and client_credentials(...) in Python. Raw, it is:

curl -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials -d scope=reports.write \
https://sso.yourcompany.com/oauth2/token

A different wire shape from the user endpoints — HTTP Basic plus a form body, and OAuth 2.0-style errors (error / error_description), which the SDKs still map onto their normal typed exceptions.

There is no refresh token — cache it

A machine token lives ~15 minutes and there is nothing to refresh; when it expires you ask for another. Cache it just under the TTL (say 14 minutes), keyed per client id, instead of fetching one per request.

4. Accept the token​

The resource server needs no secret — only the issuer and its audience, with the signing keys discovered from the issuer. Verify with the client call, then authorize by scope:

const client = verifier.verifyClient(token);
if (!client.scopes.includes('reports.write')) return res.sendStatus(403);
// client.clientId, client.orgId, client.tenantId, client.applicationId

The framework integrations do both for you — sso.client + sso.scope middleware in Laravel, SsoClientAuthenticator on a separate firewall in Symfony, requireClient in Express, and the client verifier bean in Spring. See the quickstart for your language.

Two rules that catch people​

Authorize by scope, not permissions. There is no user behind a machine token: no permissions, no roles, no org reach. Permission checks do not apply.

A machine token is tenant-wide. If your backend authenticates as a machine but acts on behalf of an end user, scope by the user — verify the forwarded user token and use its tenant, org path and subtree reach, never the machine's orgId. See Organizations & RLS.

Testing it​

# 1. A token comes back
curl -u "$CLIENT_ID:$CLIENT_SECRET" -d grant_type=client_credentials \
https://sso.yourcompany.com/oauth2/token

# 2. Wrong secret -> 401 invalid_client
curl -u "$CLIENT_ID:nonsense" -d grant_type=client_credentials \
https://sso.yourcompany.com/oauth2/token

# 3. It opens the machine route
curl -X POST -H "Authorization: Bearer $TOKEN" https://api.example.com/reports/sync

Then the negatives, which matter more: a user token on the machine route must be rejected; the machine token on a user route must be rejected; an unrequested scope must be absent from the granted scope and must produce a 403; and a client registered without applicationId must fail the audience check on a resource server configured for an application.