Skip to Content
ConceptsSigning & KeysIssuer API Keys

Issuer API Keys

Server-to-server API keys for headless custodial-issuer integrations — the “scripted/headless bulk uploader” use case for POST /api/v1/credentials/issue and POST /api/v1/credentials/batch. A BYOK issuer can already authenticate unattended (a valid signature is proof of key possession); this closes the equivalent gap for a custodial issuer (whose signing key Attest ID holds).

Design

  • Raw key shown once, only its hash persisted. atid_<32 random bytes, base64url> is generated by Core Engine, returned to the caller in the create response, and never stored or logged again. Only its SHA-256 hash and a short display prefix (atid_U62NKzp...) persist, in Issuer Registry.
  • Gateway validates without a synchronous cross-service call. API Gateway already depends on Redis for rate limiting; this reuses the same instance as a hash -> issuerDid validation cache, written at creation time and removed at revocation — the gateway still never talks to Issuer Registry or Core Engine synchronously for identity checks.

Data model

authority.issuer_api_keys (Issuer Registry):

ColumnPurpose
idKey identifier, returned in list/revoke calls
issuer_idFK to authority.issuers
key_hashSHA-256 hex digest of the raw key — unique, indexed
key_prefixFirst 12 characters of the raw key, display-only
labelCaller-supplied name (e.g. “CI bulk uploader”)
created_at / last_used_at / revoked_atLifecycle timestamps

The raw key is never written to this table, or to any log line, anywhere.

Flow

Create / use / list / revoke

StepWhat happens
CreateIssuer-portal session (or admin) calls POST /api/v1/issuer/{issuer_did}/api-keys with optional {"label": "..."}. Core Engine generates the key, hashes it, calls Issuer Registry’s CreateApiKey RPC with hash+prefix only, writes two Redis entries, returns the raw key once.
UseCaller sends X-Api-Key instead of Authorization: Bearer <jwt>. OptionalJwtTokenValidationGatewayFilter hashes it and checks Redis; a hit injects the same headers a JWT would (token_scope = "api-key"), so IssuanceOrchestrator needs no changes. A miss is 401.
ListGET /api/v1/issuer/{issuer_did}/api-keys — masked metadata only (id, prefix, label, timestamps, revoked flag), never the hash or raw key.
RevokeDELETE /api/v1/issuer/{issuer_did}/api-keys/{id} — marks revoked durably and deletes both Redis entries immediately; the key stops working on the very next request.

Authorization

The caller must be either that exact issuer’s own portal session (token_scope == "issuer-portal") or a platform admin (role == ADMIN, token_scope == "admin-console").

The gateway-verified X-Issuer-ID header carries the issuer’s UUID (authority.issuers.issuer_id), not its DID, while every API-key RPC is keyed by DID. ApiKeyService.authorize resolves the target issuer’s UUID via GetIssuer before comparing, rather than string-comparing a UUID against a DID directly.

Enforced in ApiKeyService, not the controller. The gateway route (issuer-api-keys) requires a valid JWT before the request reaches Core Engine — there is no BYOK-style alternative proof of identity for managing keys, only for using one to issue credentials.

Known limitation: Redis as a cache, not a second source of truth

If the gateway’s Redis instance is ever flushed, every live API key stops validating until recreated — there is no reconciliation job that rebuilds the cache from Issuer Registry’s durable table. Accepted as a first-release trade-off: it fails safe (keys stop working, rather than a stale entry authenticating after revocation), and the alternative — a synchronous gateway-to-backend call on every issuance request — wasn’t worth the added latency and new dependency.