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 | |
|---|---|
name | Required. Shown in the client list and the audit trail. |
orgId | Required. The customer the machine acts for — it becomes org_id / tenant_id in every token the client mints, and the usage attribution. |
applicationId | Optional, but see below. Binds the client to one application. |
scopes | Optional; 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 audienceThe aud claim on the issued token depends on it, and aud is what a resource
server checks.
- With
applicationId→audis 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 →
audis the client id itself, so verification fails unless the resource server sets its audience to that literalclient_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.
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.