Qartas Authentication
The API key
Your credential is a single API key:
cmk_live_<32 base64url characters> e.g. cmk_live_uvgADMdlIfH4GPLJ8YtQqOQkAMEo_wgR
- Issued by the operator, shown once. The server stores only the key's SHA-256 hash; the plaintext appears exactly once, in the creation response. If you lose it, it cannot be recovered — a new key is issued instead.
- 192 bits of entropy (24 random bytes, base64url), prefixed
cmk_live_so a leaked key is recognizable in logs and secret scanners. - Maps-scoped by construction. The key's record carries
scopes: ["maps"]and no role, tenant, or user identity — the schema has nothing to escalate. The auth code never converts an API key into any other kind of identity. A leaked Qartas key exposes maps endpoints only. - Keys can carry an optional expiry (
expires_at); expired keys answer401 API_KEY_EXPIRED.
Presenting the key
Send it as a header on any endpoint:
X-API-Key: cmk_live_...
That covers every JSON endpoint and — for callers that can set headers — the
tile family too. Requests with neither a valid key nor a JWT get 401
(MISSING_TOKEN on the JSON endpoints, AUTH_REQUIRED on the tile family).
Handling guidelines:
- Store it server-side or in your app's secure storage; never commit it.
- It never belongs in a URL except indirectly via the tile token below.
- The Dart SDK never logs it, never puts it in exceptions, never persists it.
Tile tokens (?t=)
Map renderers fetch tiles by URL template and often can't attach headers, so
the tile-serving family (/maps/v1/tiles, /style, /glyphs, /sprites)
also accepts a short-lived token as a query parameter:
GET /maps/v1/tiles/12/2456/1608@2x.png?t=<token>
Minting. POST /maps/v1/tile-token (authenticated with your key, no
body) returns:
{"token": "bWFwc2tleTpkYTRk...", "expires_at": "2026-07-31T22:24:16Z"}
Properties (all enforced server-side, verified live):
| Property | Value |
|---|---|
| Format | base64url of callerID\|expiry-unix\|HMAC-SHA256-signature — opaque; do not parse or construct it yourself. |
| Lifetime | 24 hours by default (expires_at tells you exactly; the operator can tune it via CHAFMAPS_TILE_TOKEN_TTL). |
| Binding | The callerID is mapskey:<your key's ID> — the token is cryptographically bound to the key that minted it. A token not bound to an API key is rejected (401 TOKEN_NOT_KEY_BOUND). |
| Scope | Tile family only. A leaked tile token cannot call autocomplete/geocode/directions/places or mint further tokens. |
| Statelessness | HMAC-verified per request — minting costs no DB work, so mint freely (one token per session is the intended pattern; it covers unlimited tile fetches while valid). |
Refresh. Mint a new token before expires_at. The Dart SDK's
client.tiles does this automatically — it caches the token and re-mints at
~80% of its lifetime; you never see a token unless you use the advanced URL-
template path (client.tiles.tokenManager.getToken()).
Failure modes:
| Response | Meaning | What to do |
|---|---|---|
401 TOKEN_EXPIRED |
Past expires_at. |
Mint a new token. |
401 INVALID_TOKEN |
Malformed or bad signature. | Re-mint; check you passed it unmodified. |
401 TOKEN_NOT_KEY_BOUND |
Token wasn't minted from an API key. | Mint via POST /maps/v1/tile-token with your key. |
401 API_KEY_REVOKED |
The key behind the token is no longer active. | Contact the operator. |
503 MAPS_AUTH_UNAVAILABLE |
Server-side auth misconfiguration. | Retry later; report if persistent. |
What happens on revoke
When the operator revokes a key (DELETE /admin/keys/{id}), the key row is
soft-deleted (revoked_at set, active=false). Effects, in order:
- Header auth dies immediately-to-promptly. The JSON endpoints check
Postgres per request — revoked keys 401 immediately there. The tile
family checks a short-TTL Redis cache (30 s default, env
CHAFMAPS_API_KEY_CACHE_TTL_SECONDS) which the revoke also proactively invalidates — so in the common case tiles 401 within seconds, and in the worst case within the cache TTL. - All previously-minted tile tokens die with the key. Token validation
re-checks that the bound key is usable right now; a revoked key's tokens
answer
401 API_KEY_REVOKEDon the same timeline as (1) — even though the token's own 24 h expiry hasn't passed. (Verified live: pre-existing tokens stopped working seconds after revoke.) - CDN edge caveat. Tiles already fetched before the revoke can keep
being served by an intermediary CDN edge cache for up to that tile's
max-age(1 h) without touching the origin. This affects only exact tile URLs already fetched; anything new hits the origin and 401s. It is expected behavior, not a revocation bug.
Expiry (expires_at on the key) behaves the same way, with
401 API_KEY_EXPIRED on the header path.
Security posture
| Layer | Posture |
|---|---|
| Transport | HTTPS only; HSTS (max-age=31536000; includeSubDomains; preload). The public host sits behind Cloudflare. |
| Response headers | X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy (camera/mic/geo denied) on every response. |
| Key storage | SHA-256 hash only; plaintext never stored or logged. |
| Key scoping | Per-key: scopes (maps only today), optional expiry, per-minute and daily limits, plan label; every request is attributed and metered to its key (tiles included). |
| Tile access | No anonymous access anywhere. Tiles require the key or a key-bound HMAC token; every tile request is attributed to a key. |
| Abuse backstops | Per-IP rate caps on the tile family run before auth, so credential-stuffing floods are capped regardless of auth outcome; request bodies capped at 1 MB; 30 s request timeout. |
| Error hygiene | All 5xx bodies are sanitized to a generic message + request_id — internal details are logged server-side, never sent on the wire. |
| Server-fault isolation | Rate limiting and metering fail open on infrastructure blips (a Redis hiccup can't take the API down), while token/secret verification fails closed (no configured secret ⇒ no tokens validate). |
Quick reference — which credential where
| Surface | X-API-Key header |
?t= tile token |
JWT bearer |
|---|---|---|---|
/maps/v1 JSON endpoints + /staticmap |
✅ | ❌ | ✅ (operator-internal) |
/maps/v1/tiles, /vector, /style, /glyphs, /sprites |
✅ | ✅ | ❌ (a JWT-minted tile token is rejected: TOKEN_NOT_KEY_BOUND) |
/admin/* |
❌ | ❌ | ✅ super_admin only |
/livez, /healthz |
not required | — | — |
Verified against internal/api/{auth,tileauth,jwtauth,admin,keycache}.go,
internal/engine/tiles.go, and live probes (mint → use → revoke → observe
401s) on 2026-07-31. Not verified from code: none for this document.