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 admin
— keys: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
plaintextfield 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_prefixis 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.