Skip to content

API keys

Customer-managed opaque tokens (Phase 2). Holders of the keys:manage scope can issue, list, and revoke keys within their own tenant through this REST surface; the dashboard sits on top of the same endpoints.

API keys complement JWTs:

Property JWT API key
Format eyJ… (3-segment, self-describing) ak_live_<random> (opaque)
Issuance Manual / IdP-issued Self-service (this endpoint)
Revocation Wait for exp or rotate signing key Instant via revoke endpoint
Verification Public key (offline) Argon2id hash + DB lookup
Best for Internal services, short-lived sessions Customer-facing integrations

The verifier accepts both transparently — the API never branches on auth method beyond the initial token-prefix dispatch.

Issue — POST /v1/api-keys

Required scope: keys:manage

Request

{
    "name": "production-app",
    "scopes": ["charts:read", "charts:write", "interpretations:write"],
    "expires_at": "2027-05-17T00:00:00Z",
    "metadata": { "environment": "prod", "team": "platform" }
}
Field Type Required Notes
name string, 1–255 yes Human label shown in the dashboard.
scopes string[] yes Subset of the platform's scope catalog — see Authentication.
expires_at ISO 8601 with TZ no Hard cutoff. null / omitted = never expires.
metadata object no Free-form labels surfaced in the list view.

Issuing a key with admin requires the caller to already hold adminkeys:manage alone cannot escalate.

Response — 201 Created

{
    "id": "019e3450-abcd-…",
    "name": "production-app",
    "key_prefix": "ak_live_abcdef12",
    "key_last4": "wxyz",
    "display": "ak_live_abcdef12…wxyz",
    "scopes": ["charts:read", "charts:write", "interpretations:write"],
    "owner_tenant_id": "tenant-a",
    "created_at": "2026-05-17T03:14:00Z",
    "created_by": "fedor",
    "last_used_at": null,
    "expires_at": "2027-05-17T00:00:00Z",
    "revoked_at": null,
    "metadata": { "environment": "prod", "team": "platform" },
    "plaintext": "ak_live_abcdef1234567890abcdef1234567890wxyz"
}

plaintext is returned exactly once and never again. Store it immediately; we hash it with argon2id and discard the original.

List — GET /v1/api-keys

Required scope: keys:manage

Query param Type Default Notes
include_revoked bool false Set true to include soft-deleted rows for audit views.

Response — 200 OK

{
    "keys": [
        {
            "id": "019e3450-…",
            "name": "production-app",
            "key_prefix": "ak_live_abcdef12",
            "key_last4": "wxyz",
            "display": "ak_live_abcdef12…wxyz",
            "scopes": ["charts:read", "charts:write", "interpretations:write"],
            "owner_tenant_id": "tenant-a",
            "created_at": "2026-05-17T03:14:00Z",
            "created_by": "fedor",
            "last_used_at": "2026-05-17T18:42:11Z",
            "expires_at": null,
            "revoked_at": null,
            "metadata": {}
        }
    ]
}

Keys are ordered by created_at descending.

Revoke — POST /v1/api-keys/{id}/revoke

Required scope: keys:manage

Soft-delete: sets revoked_at to the current server time. The verifier checks revoked_at before validating the hash, so a revoked key fails authentication on the next request — instantly across every replica.

Idempotent: revoking an already-revoked key returns the row as-is.

Response — 200 OK

Same shape as the list rows; revoked_at is now populated.

Using a key

Standard bearer header:

curl https://api.astrolinkers.com/v1/charts \
    -H "Authorization: Bearer ak_live_abcdef1234567890abcdef1234567890wxyz" \
    -H "Content-Type: application/json" \
    -d '{ "birth": { "moment": "1990-01-01T12:00:00Z", "latitude": 50, "longitude": 30 } }'

If the key is unknown, revoked, expired, or its hash does not match, the API returns 401 unauthorized with the same envelope as a malformed JWT — clients can implement a single error handler.

Security model

  • Plaintext is shown once. The dashboard surfaces it in a modal on issuance; the REST response carries it in the plaintext field of the create response and never again. Subsequent GETs only return the display tail (display: "ak_live_abcdef12…wxyz").
  • Argon2id hashing. Tunable cost parameters default to the OWASP recommendation (t=2, m=19 MiB, p=1). A single verify takes roughly 20 ms on a single CPU — adequate for the per-request hot path without becoming a brute-force vector.
  • Constant-time verification. On unknown prefix the verifier burns a dummy hash check to keep wall-clock timing flat between "unknown key" and "wrong secret".
  • Tenant isolation. Every CRUD operation is scoped to the caller's tenant_id; cross-tenant access returns 404 (we do not confirm existence of objects owned by other tenants).
  • No raw secrets in logs. The plaintext never appears in any log record; only key_prefix is logged when the verifier rejects a token.

Rate-limit semantics

API keys participate in the same per-tenant rate limit as JWT-issued calls — see Rate limits. Per-key quotas (Phase 3) will arrive with the dashboard's analytics surface.