Authentication¶
There are three independent authentication/verification mechanisms in the API, used in different places. They are not interchangeable.
User & Provider Authentication¶
Self-service (/api/v1/subs, /api/v1/me) and provider-scoped (/api/v1/providers/{user_id}/subs) endpoints authenticate via a single header:
The token identifies either a regular user account or a provider account — which one determines what the token can do, not how it's sent. A provider's own api_token additionally requires an approved connection for a given user_id before provider-scoped calls for that user succeed — see Provider API.
Token format: 43-character unpadded Base64URL string (32 random bytes).
Admin Authentication (HMAC request signing)¶
All /api/v1/admin/... endpoints require two independent checks, both enforced as FastAPI dependencies on every admin route:
- IP allowlist — the request's source IP must appear in the server's
ADMIN_ALLOWED_IPSsetting. If that setting is empty, admin access is refused entirely (403), not silently opened up. - HMAC-SHA256 request signature — via two required headers:
Signature payload — the exact string that gets HMAC'd is the concatenation, with no separators, of:
timestampis the rawX-Timestampheader value (string, milliseconds).methodis the HTTP method ("POST","PATCH", etc.).pathis the request path including the query string if one is present ("{path}?{query}") — this matters for adminGETendpoints that take query parameters.bodyis the raw request body decoded as UTF-8 (empty string for bodyless requests).
import hashlib
import hmac
import time
timestamp = str(int(time.time() * 1000))
method = "POST"
path = "/api/v1/admin/users"
body = '{"user_id": 12345}'
payload = f"{timestamp}{method}{path}{body}"
signature = hmac.new(
admin_secret_key.encode(),
payload.encode(),
hashlib.sha256,
).hexdigest()
Timestamp window: the request is rejected with 401 if abs(current_time_ms - X-Timestamp) > 60_000 (i.e. a fixed ±60 second window; this is not currently configurable via an environment variable).
Missing headers: if either X-Signature or X-Timestamp is absent, the request fails with 401 before signature verification is even attempted.
Comparison: signatures are compared with hmac.compare_digest (constant-time), not ==.
Provider Connection-Invite HMAC¶
A third, separate HMAC mechanism secures the invite link generated when a provider requests access to a user_id that has no account yet (see Provider API → Request Access to User). This is unrelated to the admin request-signing scheme above — different secret, different payload, different purpose.
- Secret:
settings.auth_hmac_secret(a separate configuration value from the adminsecret_key). - Payload signed:
"{user_id}:{provider_hash}". - Digest: HMAC-SHA256, truncated to 24 hex characters (
AUTH_HMAC_LENGTH) — short enough to fit Telegram's 64-byte?start=deep-link payload limit alongside the rest of the invite string. - Invite format:
conn_{hmac}_{provider_name}, embedded in a link of the form{connection_link_prefix}conn_{hmac}_{provider_name}(by default at.me/...?start=...Telegram deep link, configured viaCONNECTION_LINK_PREFIX). - Verification: whatever trusted service resolves the invite (e.g. a Telegram bot) calls the admin API's Process Provider Connection Request endpoint with the extracted
hmac, which the server re-derives and compares withhmac.compare_digest.
This proves the (user_id, provider_hash) pair genuinely came from an invite this API generated, rather than from a caller guessing a user_id. Because the digest is truncated, its effective security margin is bounded by 24 hex characters (96 bits) rather than the full 256-bit SHA-256 output — still infeasible to brute-force, but worth knowing if you're reasoning about the guarantees this mechanism provides.