Server-to-Server Authentication

Overview

For backend API calls (push notifications, user management, etc.), apps authenticate as themselves using the OAuth2 client credentials grant. This produces a scoped JWT — no user context needed.

Creating an API Client

There is no separate "API user" or tenant-level API key. The API identity is the app: an app's client_id / client_secret pair is its machine credential. To set one up:

  1. Create or pick an app in the Console (Apps → an existing app, or Create App). An app is created per integration — if you want an isolated credential for a job or service, create a dedicated app for it.
  2. client_id = the app's UUID (shown on the app detail page)
  3. client_secret = displayed once at creation. Lost it? Use Regenerate Secret on the app page (this immediately invalidates the old one).
  4. Grant the API scopes the integration needs, under API Scopes on the app page. An app can only request scopes granted here — anything else returns 400 invalid_scope. See Available Scopes.
  5. Request a token with the client credentials grant (below) and call the API with it.

Avoid driving the API as a human admin user. The access token from POST /auth/login carries aud = <app_id>, which /admin/* rejects — it requires the Console audience. That audience can only be obtained by emulating a browser session: log in, keep the km_sso cookie, exchange it at GET /console/login for a km_console token, then send that as your bearer. It works, but it is a browser-session emulation rather than an API contract, and it is brittle: enabling 2FA on the account breaks it outright (login returns totp_required instead of tokens), and it is additionally subject to password rotation, login rate limits (5 per 15 min per email), an 8-hour SSO session, and a 60-minute Console token. Use client credentials for anything a service token can do.

What service tokens can and cannot do

Service tokens are deliberately narrow. They work on:

Endpoint Scope Scoping
POST /push/send, /push/send/bulk, /push/send/batch push:send users enrolled in the calling app
GET /admin/users?app_id=… users:read must be the calling app's own app_id
POST /admin/users users:create creates a global user record; does not enroll them
POST /admin/invites invites:create own app only; roles forced to ["user"]
GET /admin/apps apps:read apps in the calling app's own tenant (redacted config)
POST /admin/apps, PUT /admin/apps/{id} apps:write apps in the calling app's own tenant

Everything else on /admin/* — assigning roles, tenant CRUD, suspending or deleting users, reading the audit log — requires an interactive Console (browser) session and is not reachable with a service token today.

A common pattern for onboarding a user programmatically is therefore: POST /admin/users to create the identity, then POST /admin/invites so they can enroll themselves (a service token cannot assign roles directly).

Tenant-Level App Provisioning

apps:read / apps:write turn one app's credentials into a tenant-scoped provisioning key — the closest thing Keymaster has to a "tenant API key". The natural holder is the tenant's Console app, but any app in the tenant can hold it.

Because it is a privilege-escalation surface, only a platform admin can grant it (Console → app → API Scopes).

POST /admin/apps
Authorization: Bearer <service token with apps:write>
Content-Type: application/json

{
  "tenant_id": "<must equal the calling app's tenant>",
  "name": "Reporting Service",
  "registration_policy": "invite",
  "config": { "redirect_uris": ["https://reports.example.com/auth/callback"] }
}

The response includes client_secret once — the new app's own credentials.

Guardrails

These are enforced server-side and cannot be waived by the caller:

Attempt Result
tenant_id differs from the calling app's tenant 403
Calling app's tenant is inactive 403
config.service_scopes in the payload 403 — a service token can never grant scopes, to itself or to an app it creates
config.is_console_app in the payload 403 — would mint a tenant-admin surface
Console-reserved bundle_id 403

apps:read responses redact service_scopes and any push webhook_secret / webhook_secret_enc.

Audit entries for these calls record "source": "service_token" plus the acting app id (created_by_app / updated_by_app).

Note: apps:write cannot rotate an app's client secret (PUT /admin/apps/{id}/secret remains Console-only). Provision the app, capture the secret from the create response, and store it — a lost secret needs a platform/tenant admin to regenerate.

Client Credentials Flow

1. Request a Service Token

POST /auth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=a56e4998-e65d-4817-b69d-009ab7dee28f
&client_secret=your_client_secret_here
&scope=push:send

The request may also be sent as a JSON body with the same fields. scope is space-separated.

Scope entitlement: an app is only granted scopes it is entitled to. Each requested scope must be listed in the app's config["service_scopes"] in Keymaster, otherwise the request fails with 400 invalid_scope. Ask a Keymaster admin to add a scope to your app's config before requesting it.

2. Receive a Scoped JWT

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "bearer",
  "expires_in": 900,
  "scope": "push:send"
}

3. Use the Token

POST /push/send
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

{
  "user_id": "...",
  "title": "Your shift starts in 30 min",
  "body": "Open GymOps to view details."
}

Service Token Claims

{
  "sub": "a56e4998-e65d-4817-b69d-009ab7dee28f",
  "iss": "https://keymaster.cloud-monitor.com",
  "scope": "push:send",
  "token_type": "service",
  "iat": 1710547200,
  "exp": 1710548100
}
Claim Description
sub App UUID (NOT a user — this is the app itself)
scope Space-delimited scopes granted
token_type Always "service" — distinguishes from user JWTs

There is no aud claim on service tokens. expires_in is 900 seconds.

Available Scopes

Scope Grants access to Constraint
push:send /push/send, /push/send/bulk, /push/send/batch Own app's enrolled users only
invites:create POST /admin/invites Own app only (app_id must match token sub)
users:read GET /admin/users?app_id= Own app only (must specify app_id query param)
users:create POST /admin/users Cannot set is_platform_admin=true
apps:read GET /admin/apps Own tenant only; config is returned redacted
apps:write POST /admin/apps, PUT /admin/apps/{id} Own tenant only; cannot set service_scopes, is_console_app, or a Console-reserved bundle_id

Request multiple scopes in one token by space-delimiting them:

scope=invites:create users:read users:create

Security

Implementation Pattern

import httpx
import time

class KeymasterClient:
    """Keymaster server-to-server client with automatic token management."""

    def __init__(self, base_url: str, client_id: str, client_secret: str):
        self.base_url = base_url
        self.client_id = client_id
        self.client_secret = client_secret
        self._token = None
        self._token_expires = 0

    def _get_token(self, scope: str = "push:send") -> str:
        """Get a valid service token, refreshing if expired."""
        if self._token and time.time() < self._token_expires - 30:
            return self._token

        resp = httpx.post(f"{self.base_url}/auth/token", data={
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "scope": scope,
        })
        resp.raise_for_status()
        data = resp.json()
        self._token = data["access_token"]
        self._token_expires = time.time() + data["expires_in"]
        return self._token

    def send_push(self, user_id: str, title: str, body: str, data: dict = None):
        """Send a push notification to a user."""
        token = self._get_token("push:send")
        resp = httpx.post(
            f"{self.base_url}/push/send",
            headers={"Authorization": f"Bearer {token}"},
            json={"user_id": user_id, "title": title, "body": body, "data": data or {}},
        )
        resp.raise_for_status()
        return resp.json()

    def create_invite(self, email: str, roles: list[str] = None, expires_in_days: int = 7):
        """Create an invite and email it to the user."""
        token = self._get_token("invites:create")
        resp = httpx.post(
            f"{self.base_url}/admin/invites",
            headers={"Authorization": f"Bearer {token}"},
            json={
                "app_id": self.client_id,
                "send_to_email": email,
                "roles": roles or [],
                "max_uses": 1,
                "expires_in_days": expires_in_days,
            },
        )
        resp.raise_for_status()
        return resp.json()

    def list_users(self):
        """List all users enrolled in this app."""
        token = self._get_token("users:read")
        resp = httpx.get(
            f"{self.base_url}/admin/users",
            headers={"Authorization": f"Bearer {token}"},
            params={"app_id": self.client_id},
        )
        resp.raise_for_status()
        return resp.json()

    def create_user(self, email: str, display_name: str):
        """Create a user stub (no password — they'll set one via invite/OAuth)."""
        token = self._get_token("users:create")
        resp = httpx.post(
            f"{self.base_url}/admin/users",
            headers={"Authorization": f"Bearer {token}"},
            json={"email": email, "display_name": display_name},
        )
        resp.raise_for_status()
        return resp.json()
// Node.js equivalent
class KeymasterClient {
  constructor(baseUrl, clientId, clientSecret) {
    this.baseUrl = baseUrl;
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.token = null;
    this.tokenExpires = 0;
  }

  async getToken(scope = 'push:send') {
    if (this.token && Date.now() / 1000 < this.tokenExpires - 30) {
      return this.token;
    }
    const resp = await fetch(`${this.baseUrl}/auth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: this.clientId,
        client_secret: this.clientSecret,
        scope,
      }),
    });
    const data = await resp.json();
    this.token = data.access_token;
    this.tokenExpires = Date.now() / 1000 + data.expires_in;
    return this.token;
  }

  async sendPush(userId, title, body, data = {}) {
    const token = await this.getToken('push:send');
    const resp = await fetch(`${this.baseUrl}/push/send`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ user_id: userId, title, body, data }),
    });
    return resp.json();
  }

  async createInvite(email, roles = [], expiresInDays = 7) {
    const token = await this.getToken('invites:create');
    const resp = await fetch(`${this.baseUrl}/admin/invites`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        app_id: this.clientId,
        send_to_email: email,
        roles,
        max_uses: 1,
        expires_in_days: expiresInDays,
      }),
    });
    return resp.json();
  }

  async listUsers() {
    const token = await this.getToken('users:read');
    const resp = await fetch(
      `${this.baseUrl}/admin/users?app_id=${this.clientId}`,
      { headers: { 'Authorization': `Bearer ${token}` } },
    );
    return resp.json();
  }

  async createUser(email, displayName) {
    const token = await this.getToken('users:create');
    const resp = await fetch(`${this.baseUrl}/admin/users`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ email, display_name: displayName }),
    });
    return resp.json();
  }
}

Error Responses

Status Error Meaning
400 unsupported_grant_type Only client_credentials is supported
400 invalid_scope: xyz Scope is unrecognized, or the app is not entitled to it (config["service_scopes"])
401 invalid_client Client ID not found, secret wrong, or app inactive
429 too_many_requests Rate limited — check Retry-After header