Skip to content

Authentication

Every endpoint outside /healthz, /readyz, /version, /metrics, /docs, /redoc and /openapi.json requires a valid bearer token.

Authorization: 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:

  1. JWKS endpointJWT_JWKS_URL configured. Keys are fetched and cached (JWT_JWKS_CACHE_TTL_SECONDS, default 600 s); the token's kid header selects which key to use. This is the production path — it supports rotation without a redeploy.
  2. Static PEMJWT_PUBLIC_KEY env var. Useful for single-issuer environments where the IdP is fixed.
  3. 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:

{
    "scope": "charts:read charts:write interpretations:write"
}

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>