Authentication¶
Every endpoint outside /healthz, /readyz, /version, /metrics,
/docs, /redoc and /openapi.json requires a valid bearer token.
Two token formats¶
The API accepts two complementary credentials; both produce the
same Principal and downstream scope guards do not branch on which
was used.
| Format | Prefix | Issued by | Verified by | Best for |
|---|---|---|---|---|
| JWT | eyJ… (3-segment) |
Platform / IdP signing key | Public key + claim check | Internal services, short-lived sessions |
| API key | ak_live_… |
The customer (dashboard / REST) | Argon2id hash + DB lookup | Customer-facing integrations |
The middleware dispatches by prefix: tokens starting with ak_live_
go to the API-key verifier; anything else falls through to the JWT
verifier. See API keys for the customer-facing
CRUD surface.
Supported JWT signing schemes¶
- RS256 (RSA 2048+)
- ES256 (ECDSA P-256)
- EdDSA (Ed25519)
HS256 (shared secret) is intentionally not supported — the API is asymmetric only.
Key resolution¶
The API resolves verification keys in this priority order:
- JWKS endpoint —
JWT_JWKS_URLconfigured. Keys are fetched and cached (JWT_JWKS_CACHE_TTL_SECONDS, default 600 s); the token'skidheader selects which key to use. This is the production path — it supports rotation without a redeploy. - Static PEM —
JWT_PUBLIC_KEYenv var. Useful for single-issuer environments where the IdP is fixed. - No verifier — neither configured. The middleware attaches a
synthetic admin principal. Only for local development —
refuses to enable in
ENVIRONMENT=prod.
Required claims¶
| Claim | Required | Notes |
|---|---|---|
sub |
yes | Stable identifier of the calling user / service. |
iss |
yes | Must equal JWT_ISSUER. |
aud |
yes | Must contain JWT_AUDIENCE (default astro-platform). |
iat |
yes | Issued-at, UTC seconds. |
exp |
yes | Expiry, UTC seconds. Tokens past exp are rejected. |
scope |
recommended | Space-separated scope strings. See below. |
tenant_id |
recommended | Used by tenant isolation + LLM cost cap. |
A 30-second leeway is applied on nbf / exp to absorb modest clock
skew.
Scopes¶
The platform issues OAuth-style scopes. Routes declare the minimum scope they require; missing scope → HTTP 403.
| Scope | Allows |
|---|---|
charts:read |
GET /v1/geolocation/places, GET /v1/charts/*, talent profile |
charts:write |
POST /v1/charts |
interpretations:read |
GET /v1/interpretations/{id} |
interpretations:write |
POST /v1/interpretations, POST /v1/interpretations/natal, POST /v1/interpretations/relationship, POST /v1/interpretations/forecast, POST /v1/interpretations/vedic/* |
reports:read |
GET /v1/reports/{id} |
reports:write |
POST /v1/reports |
compatibility:read |
GET /v1/compatibility/{id} |
compatibility:write |
POST /v1/compatibility |
feedback:write |
POST /v1/feedback |
keys:manage |
POST/GET /v1/api-keys, POST /v1/api-keys/{id}/revoke — manage your tenant's keys |
admin |
Grants every scope above + cross-tenant operations. Reserve for service tokens. |
Multiple scopes go in one space-separated string:
Tenant isolation¶
If your token carries tenant_id, every read is filtered to that tenant
and every write is tagged with it. A token without tenant_id falls
back to the default tenant — useful for single-tenant deployments,
risky for multi-tenant SaaS. Always include tenant_id for production
traffic.
Cross-tenant reads (chart A's tenant trying to read chart B owned by another tenant) return HTTP 404, not 403 — the API does not confirm or deny the existence of objects owned by other tenants.
Failure modes¶
| Reason | HTTP | Body details.reason |
|---|---|---|
Missing Authorization header |
401 | missing_bearer |
| Malformed JWT | 401 | invalid_token: <jwt_lib_message> |
| Expired token | 401 | invalid_token: signature has expired |
| Wrong issuer / audience | 401 | invalid_token: <issuer\|audience> mismatch |
| JWKS endpoint unreachable | 503 | auth_unavailable (after cache TTL) |
| Valid token, missing scope | 403 | forbidden_scope: <scope_required> |