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