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:
- 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.
client_id= the app's UUID (shown on the app detail page)client_secret= displayed once at creation. Lost it? Use Regenerate Secret on the app page (this immediately invalidates the old one).- 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. - 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/logincarriesaud = <app_id>, which/admin/*rejects — it requires the Console audience. That audience can only be obtained by emulating a browser session: log in, keep thekm_ssocookie, exchange it atGET /console/loginfor akm_consoletoken, 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 returnstotp_requiredinstead 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:writecannot rotate an app's client secret (PUT /admin/apps/{id}/secretremains 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_afteris 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_activeis not the archived flag. An archived app can still beis_active: true— the two are independent. Branch onarchived_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_idis the only guaranteed-unique lookup key. Keymaster does not reserve abundle_idagainst 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 with400 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
- 15-minute TTL — Service tokens expire quickly. Cache and reuse, then re-authenticate.
- No refresh tokens — Client credentials tokens don't have refresh tokens. Just request a new one.
- Rate limited — 10 attempts per 15 minutes per client_id. Cleared on successful auth.
- Scope enforcement — Endpoints verify the required scope is present in the token.
- Type separation —
token_type: "service"prevents service tokens from being used as user tokens (and vice versa).
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 |