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 -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 (prefixedgate_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.curl -sS -X POST https://evonia-gate.annatarhe.com/api/auth/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope="usage:write" \
-d resource=https://api.shelltime.xyzform := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("scope", "usage:write")
form.Set("resource", "https://api.shelltime.xyz")
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
"https://evonia-gate.annatarhe.com/api/auth/oauth2/token", strings.NewReader(form.Encode()))
req.SetBasicAuth(clientID, clientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var token struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(res.Body).Decode(&token); err != nil {
return "", err
}var request = URLRequest(
url: URL(string: "https://evonia-gate.annatarhe.com/api/auth/oauth2/token")!
)
request.httpMethod = "POST"
let credentials = Data("\(clientID):\(clientSecret)".utf8).base64EncodedString()
request.setValue("Basic \(credentials)", forHTTPHeaderField: "Authorization")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(
"grant_type=client_credentials&scope=usage:write&resource=https://api.shelltime.xyz".utf8
)
let (data, _) = try await URLSession.shared.data(for: request)
let token = try JSONDecoder().decode(TokenResponse.self, from: data)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/apihttps://api.shelltime.xyzhttps://api.rawback.app- anything listed in the
GATE_ALLOWED_AUDIENCESenvironment 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.
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:
{
"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.