Keyring

Embed tokens

Minting the five-minute token the Manage API keys component uses.

Generated from packages/api/src/embed/embed-tokens.controller.ts by packages/docs/tools/generate-reference.mjs. Every example is a real exchange recorded against the control plane by packages/api/src/testing/reference-examples.spec.ts, with ids from that run. Credentials in the formats this product issues (kr_, krsk_, krses_, JWTs) and values under credential-named fields (password, *_secret, *_token, *_key, lookup_hash) are elided at record time; the recorded file and every page of this site are swept for credential shapes (kr_, krsk_, krses_, JWTs, URLs carrying a password, password fields) before either is accepted. Do not edit by hand: src/reference.spec.ts regenerates it and fails on a difference.

Report section 7.2, step 1: the vendor's backend exchanges its krsk_ key for a short-lived, tenant-scoped token.

Two decisions worth stating, because both look like omissions:

No audit row and no advisory lock. Migration 0003's audit trigger serialises appends per workspace, and this endpoint is called once per end-user session per five minutes — it scales with the vendor's customers, not with the vendor. Auditing it would put the busiest control-plane endpoint behind the same per-workspace lock that made an unrelated read take 22.7 s in the week 2 review. The revocation is audited (POST /v1/tenants/:id/embed_tokens/revoke), which is the event an auditor asks about; the mints are the vendor's own traffic.

SecretKeyGuard, not HotSecretKeyGuard. The hot cache's own contract says it is for the rate-limit and idempotency endpoints and must not be widened, and it should not be: a token minted with a krsk_ key revoked five seconds ago is a five-minute credential, not one request.

POST /v1/embed_tokens

NoOriginGuard runs first and is the whole of report section 7.3's fifth row. A browser cannot suppress Origin on a cross-origin request, so its presence means a krsk_ key reached browser-delivered code — and the answer has to say that loudly enough that someone rotates the key.

Answers 201 on success.

Authentication.

  • Refused when the request carries an Origin header: this is called from a backend, never from a browser.
  • A krsk_ secret key only. A person's session is refused on this plane.

Body. Validated by this schema, from the control plane's own source:

const MintEmbedToken = z.object({
  project_id: z.uuid(),
  env: Env,
  tenant_id: z.uuid(),
  scopes: z
    .array(z.string().min(1).max(220))
    .max(32)
    .default(['keys:read'])
    .refine((scopes) => scopes.every(isEmbedScope), {
      message:
        'must be keys:read, keys:write, or key_scope:<scope the session may put on a key>',
    }),
  /**
   * Which of the workspace's registered origins this token is for. Omitted
   * means all of them, which is the useful default for a vendor with one
   * dashboard; naming a subset is for a vendor whose staging and production
   * dashboards should not be able to replay each other's tokens.
   */
  origins: z.array(z.string().min(1).max(255)).max(20).optional(),
});

Example.

Request
curl https://keyring-api.belghalem.fr/v1/embed_tokens \
  -X POST \
  -H "Authorization: Bearer $KEYRING_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
    "env": "test",
    "tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
    "scopes": [
      "keys:read",
      "keys:write",
      "key_scope:orders:read"
    ]
  }'
Response
HTTP/1.1 201 Created
Content-Type: application/json

{
  "object": "embed_token",
  "token": "eyJ…",
  "jti": "aa00bc4a-0fa2-41ed-b2b0-2cfe685abe2e",
  "kid": "6ZPqMRdXlSH_yhUe6GFcxOyCj9jqh2mv_pchlfeoWT8",
  "project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
  "env": "test",
  "tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
  "tenant_external_id": "cus_8f3k2",
  "scopes": [
    "keys:read",
    "keys:write",
    "key_scope:orders:read"
  ],
  "audience": [
    "https://app.acme.example"
  ],
  "epoch": 1,
  "issued_at": "2026-09-17T05:47:20.162Z",
  "expires_at": "2026-09-17T05:52:20.162Z",
  "expires_in": 300,
  "refresh_after": "2026-09-17T05:51:20.162Z"
}

Shared validators

Defined once in packages/api/src/validation.ts and used by the schemas above.

const Env = z.enum(['live', 'test']);

On this page