Authentication
#Authentication
The API uses OAuth 2.0 Client Credentials (RFC 6749 §4.4) — the standard machine-to-machine grant every HTTP library and OAuth SDK already supports. There is no user login, no redirect flow, no refresh token.
#The two credentials
| Credential | Looks like | Lifetime | Who has it |
|---|---|---|---|
client_id |
scc_3f9a1c0d2b7e4a6f8c1d2e3f |
until the client is deleted | public — identifies the client, not a secret itself |
client_secret |
scs_q0vX… (43 chars) |
until rotated | secret — shown once at creation/rotation, stored at our end only as a salted hash |
| access token | sca_… (43 chars) |
1 hour | your server, in memory — never persisted, never logged |
Losing the client_secret? It cannot be retrieved — ask the club admin to rotate it (Settings → API
access → the client → Rotate secret). Rotation immediately invalidates the old secret and every access
token issued from it.
#A note on encoding
client_id/client_secret only ever use the base64url alphabet (letters, digits, -, _) plus their fixed
scc_/scs_ prefix — they never contain a : or any character that needs percent-encoding. Every sample on
this site sends them as-is, client_id:client_secret, base64-encoded for the Authorization: Basic header;
you do not need to URL-encode either value first.
#Getting a token
POST /api/v1/oauth/token — see the full reference: Get access token. Two equivalent
ways to send the credentials: HTTP Basic (recommended — never appears in a request body log), or as
client_id/client_secret form fields. Sending both at once is a 400 API_TOKEN_REQUEST_INVALID.
curl -s -X POST https://sail-club-server.cloud.run/api/v1/oauth/token \
-u "scc_3f9a…:scs_q0vX…" \
-d grant_type=client_credentialsThe response carries expires_in (always 3600, but read the field — don't hard-code it) and scope
(the token's actual scopes, which may be a client asked for a narrower scope= than it holds).
#Refresh-before-expiry pattern
if no cached token OR cached token expires within the next ~60s:
fetch a new token, cache { access_token, expires_at = now + expires_in }
use the cached access_token on the call
if the call returns 401:
fetch exactly one new token and retry the call once
if it still 401s, stop and alert a human — the credential itself is the problemDon't fetch a token per API call — the token endpoint has its own, tighter rate limit (20/min per client and per caller IP — see Rate limits) and every extra round-trip is latency you don't need, since one token covers an hour of calls.
#Secret handling
- Read
client_id/client_secretfrom environment variables or a secrets manager — never commit them, never put them in a repository, never send them to a browser (this API has no CORS and rejects the Firebase user tokens the panel itself uses, so there is no browser-safe way to call it anyway). - Never log the access token or the client secret, including in error messages or crash reports.
- One client per integration/environment (e.g. a separate client for staging vs. production) makes rotation and revocation blast-radius small.
#Rotation
The club admin can rotate a secret at any time from the panel. Effect: the old secret stops working for new token requests immediately, and every access token already issued from it stops working within 60 seconds (client state is cached at our end for up to a minute). Build your token refresh path to treat a sudden run of 401s as "credential changed, alert a human, don't just retry forever."
#What a Firebase (panel) token cannot do
The panel's own users authenticate with Firebase ID tokens. Those are a completely separate scheme from
this API: a Firebase token is rejected on every /api/v1/* route (401 API_TOKEN_INVALID), and an API
access token is rejected everywhere else. If you're building against this API, you should never see or
handle a Firebase token at all.