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.

App Lifecycle: Archive, Restore, Purge

Deleting an app is a two-stage process, so an offboarding automation can act immediately without risking irreversible data loss.

active  ──archive──>  archived  ──retention elapses (or forced)──>  purged
             ^                                                         │
             └──────────────── restore ────────────────┘         (terminal)

Archive takes effect immediately — the app stops working everywhere (no logins, no token issuance, hidden from app pickers) and its live refresh tokens are revoked — but every row is retained and the app can be restored until purge_after. Default retention is 30 days (APP_ARCHIVE_RETENTION_DAYS).

Purge is terminal: dependent rows are deleted and the app row is removed.

purge_after is a target, not a floor. It is when the automatic sweep becomes eligible to purge, and a platform admin can force a purge at any time before it. Do not source a contractual grace period or win-back window from this field — the client registrations behind it can legitimately disappear sooner. If you need a guaranteed retention floor, hold that promise in your own system, not in Keymaster.

Archive an app

DELETE /admin/apps/{app_id}
Authorization: Bearer <service token with apps:write, or a Console session>
Content-Type: application/json

{ "reason": "tenant offboarded" }
{ "status": "archived", "app_id": "…", "purge_after": "2026-09-19T…", "restorable": true }

Restore

POST /admin/apps/{app_id}/restore

Users must re-authenticate — tokens revoked at archive time stay revoked.

Purge immediately (platform admin only)

DELETE /admin/apps/{app_id}
{ "purge_now": true, "confirm_name": "<the app's exact name>", "reason": "…" }

confirm_name must match the app name exactly, or the request is refused.

Listing apps by lifecycle state

GET /admin/apps?status= takes active (default), archived, or all.

Every app record carries its own lifecycle state, so a status=all result is partitionable without a second call:

{
  "id": "…", "name": "Booking", "is_active": true,
  "archived_at": "2026-08-20T13:00:00Z",
  "purge_after": "2026-09-19T13:00:00Z",
  "archive_reason": "tenant offboarded"
}

is_active is not the archived flag. An archived app can still be is_active: true — the two are independent. Branch on archived_at.

Provisioning must use status=all. Asking "does this tenant already have a booking app?" against the default listing returns no for an archived one, so a naive create path silently produces a duplicate and orphans the archived app along with every client registration inside it.

An archived app still owns its bundle_id

bundle_id is not unique in the schema and never has been. To stop the failure above from being silent, POST /admin/apps and PUT /admin/apps/{id} reject a bundle_id that is already claimed by another app — including an archived one — with 409 and the conflicting app's id:

{
  "detail": "bundle_id 'app.example.booking' belongs to an ARCHIVED app (a1b2…, 'Booking'). Restore it instead of creating a duplicate: POST /admin/apps/a1b2…/restore. …"
}

The correct reprovisioning flow for a returning tenant is therefore restore, not create. The check only fires when the value actually changes, so historical duplicates stay editable.

app_id is the only guaranteed-unique lookup key. Keymaster does not reserve a bundle_id against a second claimant beyond this check — on mobile, the App Link / Universal Link association files are what actually bind it.

Who can do what

Action apps:write service token Tenant / app admin Platform admin
Archive ✅ own tenant
Restore ✅ own tenant
Purge now
Archive a tenant Console app

Archiving is delegable precisely because it is reversible. An app may not archive itself.

What a purge keeps

Purging deletes the app's enrollments, invites, refresh tokens, magic links, push devices, and webhook endpoints/deliveries. It deliberately keeps the audit log: audit_log retains the original app_id, and a row is written to deleted_entities recording the app's name, bundle id, tenant, and purge timestamp — so historical audit entries stay resolvable to a human-readable name instead of a dangling UUID.

Automatic purging

A background sweep runs every APP_PURGE_SWEEP_HOURS (default 6) and purges any archived app past its purge_after. A platform admin can trigger it on demand:

POST /admin/maintenance/purge-archived

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}, DELETE /admin/apps/{id} (archive), POST /admin/apps/{id}/restore Own tenant only; cannot set service_scopes, is_console_app, or a Console-reserved bundle_id; may archive but not purge

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