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.

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

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

Available Scopes

Scope Grants access to
push:send Push notification endpoints (/push/send, /push/send/bulk, /push/send/batch)

More scopes will be added as features are built.

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()
// 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();
  }
}

Error Responses

Status Error Meaning
400 unsupported_grant_type Only client_credentials is supported
400 invalid_scope: xyz Requested scope is not recognized
401 invalid_client Client ID not found, secret wrong, or app inactive
429 too_many_requests Rate limited — check Retry-After header