Machine to machine

When one of your backends calls another and no user is involved, use the client credentials grant. You get a short-lived bearer token that the receiving service verifies against Gate's JWKS — the same verification path as a user token, so the receiver needs only one code path.

If instead you want to call Gate's own /api/v1 endpoints, a service API key is simpler and does not expire every five minutes.

Register a service client

curl
curl -sS -X POST \
  "https://evonia-gate.annatarhe.com/api/v1/projects/$PROJECT_ID/oidc-clients" \
  -H "content-type: application/json" \
  -b "better-auth.session_token=$SESSION" \
  -d '{
    "name": "ShellTime Ingest Worker",
    "type": "service",
    "environmentId": "'"$ENVIRONMENT_ID"'",
    "scopes": ["usage:write"]
  }'

A service client differs from the others:

  • Its only grant type is client_credentials. It cannot run an authorization code flow.
  • It needs no redirect URIs; Gate registers a placeholder for you.
  • It authenticates with client_secret_basic, and the secret (prefixed gate_cs_) is returned once.
  • It never receives a refresh token.

Request a token

const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");

const response = await fetch("https://evonia-gate.annatarhe.com/api/auth/oauth2/token", {
  method: "POST",
  headers: {
    authorization: `Basic ${basic}`,
    "content-type": "application/x-www-form-urlencoded",
  },
  body: new URLSearchParams({
    grant_type: "client_credentials",
    scope: "usage:write",
    // Which API will accept this token. Must be an allowed audience.
    resource: "https://api.shelltime.xyz",
  }),
});

const { access_token, expires_in } = await response.json();
// expires_in is 300 for machine-to-machine tokens.

Choosing the resource

resource names the API that will accept the token; it becomes the audience the receiver checks. Gate accepts these values:

  • https://evonia-gate.annatarhe.com/api
  • https://api.shelltime.xyz
  • https://api.rawback.app
  • anything listed in the GATE_ALLOWED_AUDIENCES environment variable, comma separated

An unlisted value is rejected. Unlike origins, audiences are configurable without a code change — see Self-hosting.

Caching the token

Machine-to-machine tokens live 300 seconds. Minting one per request wastes a round trip and will eventually rate-limit you; caching one for an hour will fail. Cache in memory, keyed by (clientId, scope, resource), and refresh slightly early.

TypeScript
type CachedToken = { token: string; expiresAt: number };
const cache = new Map<string, CachedToken>();

export async function getServiceToken(resource: string, scope: string): Promise<string> {
  const key = `${resource}|${scope}`;
  const cached = cache.get(key);
  // Re-mint 30s early so an in-flight request cannot be rejected mid-call.
  if (cached && cached.expiresAt - 30_000 > Date.now()) return cached.token;

  const minted = await requestClientCredentialsToken(resource, scope);
  cache.set(key, {
    token: minted.access_token,
    expiresAt: Date.now() + minted.expires_in * 1000,
  });
  return minted.token;
}

Claims on the token

Gate adds four namespaced claims describing where the client belongs:

JSON
{
  "iss": "https://evonia-gate.annatarhe.com/api/auth",
  "aud": "https://api.shelltime.xyz",
  "exp": 1767225900,
  "scope": "usage:write",
  "https://evonia-gate.annatarhe.com/organization_id": "org_...",
  "https://evonia-gate.annatarhe.com/project_id": "6f1c...",
  "https://evonia-gate.annatarhe.com/environment_id": "b47a...",
  "https://evonia-gate.annatarhe.com/resource": "https://api.shelltime.xyz"
}

The project and environment claims come from the client's registration, so a receiving service can tell which environment a caller belongs to without a lookup. Use them for routing and tenancy — but authorize on scope.

Rotating the secret

Client secrets do not expire. Rotate them deliberately: register a second client, migrate callers, then disable the first with POST /api/v1/projects/{projectId}/oidc-clients/{clientId}/disable. That endpoint requires a user session with credential:manage.

Next: the receiving side of this token, in Verifying tokens.