Skip to content

API reference ​

The API is served under /v1. Requests and responses use JSON envelopes.

Success ​

json
{ "success": true, "data": { } }

Error ​

json
{
  "success": false,
  "error": {
    "code": "secret_not_found",
    "message": "Secret not found",
    "statusCode": 404,
    "timestamp": "2026-10-29T12:00:00.000Z",
    "requestId": "6f1c...",
    "path": "/v1/data/demo/secrets/db-password"
  }
}

Request ids use the X-Request-ID header. Production errors never contain secret values or stack traces.

Authentication ​

CallerMethod
Browser__Host-ov_session cookie plus X-CSRF-Token on mutations
CLIAuthorization: Bearer <access token>
AgentmTLS client certificate; /v1/agent/join uses a single-use join token

The browser session cookie is __Host-ov_session: HttpOnly, Secure, SameSite=Lax, Path=/, and no Domain. Session ids are random and stored only as a hash. The absolute lifetime is OPENVAULT_SESSION_TTL_HOURS (default 8). Refresh rotates the secret and CSRF token. OIDC login additionally uses a short-lived __Host-ov_oidc binding cookie; the callback rejects a flow without the matching binding.

A failed local login returns 401 invalid_credentials with the same body for an unknown user, a wrong password, a disabled principal, or a locked account. Five failures in a rolling 15-minute window lock the account for 15 minutes. Login is limited to 5/min per IP and 10/hour per account; a rejected limit returns 429 RATE_LIMIT_EXCEEDED.

While mustChangePassword is true, the browser may use only GET /v1/auth/me, POST /v1/auth/password, POST /v1/auth/logout, and GET /v1/meta/*. The CLI may use only GET /v1/auth/cli/me and POST /v1/auth/cli/logout. Other authenticated routes return 403 password_change_required.

Routes ​

Authentication ​

MethodPathNotes
GET/v1/auth/loginStarts OIDC with PKCE, state, nonce, and the binding cookie
GET/v1/auth/callbackValidates state, binding, nonce, and UserInfo sub
POST/v1/auth/local/loginLocal password; sets the session cookie
POST/v1/auth/passwordChanges the current password; rotates the session
POST/v1/auth/logoutClears the session
GET/v1/auth/meSession user, CSRF token, and forced-change flag
POST/v1/auth/refreshRotates the session secret and CSRF token
GET/v1/auth/sessionsLists the current principal's sessions
DELETE/v1/auth/sessions/:idRevokes an owned session by its public id
POST/v1/auth/cli/loginReturns an access token and rotating refresh token
POST/v1/auth/cli/refreshRotates the access and refresh tokens
POST/v1/auth/cli/logoutRevokes the refresh family; idempotent
GET/v1/auth/cli/meCurrent CLI user and forced-change flag
GET/v1/auth/cli/sessionsLists the current principal's CLI families
DELETE/v1/auth/cli/sessions/:familyIdRevokes an owned CLI family
POST/v1/agent/joinToken-authenticated bootstrap; no client certificate required
POST/v1/agent/renewmTLS renewal with the current certificate
POST/v1/agent/consumermTLS aggregate consumer heartbeat

Local login and password change are origin-guarded. The login body is { "identifier": "...", "password": "..." }; the password-change body is { "currentPassword": "...", "newPassword": "..." } with a 12–1024 character new password. The session list returns public ids, timestamps, auth method, and a current flag; it never returns the secret or CSRF token.

OIDC identities link by (issuer, subject). JIT provisioning requires email_verified: true, an exact allowed-domain match, and an unused normalized email; a verified email that belongs to another principal is rejected rather than auto-linked. OIDC access, refresh, and ID tokens are discarded after login and never enter the session cache, the database, or request logs. A provider timeout or 5xx returns 503 oidc_unavailable; a bad code, state, nonce, binding, or UserInfo subject returns 400 oidc_callback_failed.

CLI authentication ​

CLI login uses the same local credential verifier, lockout state, generic failure body, and per-IP/per-account rate-limit buckets as browser login.

text
POST /v1/auth/cli/login
{ "identifier": "alice@example.com", "password": "..." }

POST /v1/auth/cli/refresh
{ "refreshToken": "<43-character base64url>" }

POST /v1/auth/cli/logout
{ "refreshToken": "<43-character base64url>" }

Login and refresh return: { accessToken, refreshToken, tokenType: "Bearer", accessExpiresAt, refreshExpiresAt, user, mustChangePassword }. The access token is short-lived (15 minutes) and scoped to the CLI. The refresh token is random, not an access token. A family has an absolute 12-hour lifetime from login; rotation does not extend it. Reuse of a rotated or revoked token, logout, password change, or principal disable revokes the family.

CLI requests use Authorization: Bearer <access token> and must not send a browser cookie or CSRF token. Every request verifies the token and then checks that the principal is active and the family has a live row. A revoked family or disabled principal returns 401 authentication_required; a bad login returns 401 invalid_credentials; an unknown, expired, or reused refresh token returns 401 invalid_refresh_token. Refresh is limited to 60/minute per IP and 30/minute per family; a rejected limit returns 429 RATE_LIMIT_EXCEEDED. Logout returns 204 for any well-formed token, including an already-revoked one, and 400 validation_error for a malformed token.

Service identities ​

text
POST /v1/admin/identities
{ "name": "payments-api", "projectId": "01J..." }
-> 201 { "id", "name", "projectId", "status", "createdAt" }

GET /v1/admin/identities?projectId=01J...
-> 200 { "identities": [ { "id", "name", "projectId", "status", "createdAt" } ] }

GET /v1/admin/identities/:id
-> 200 {
     "id", "name", "projectId", "status", "createdAt",
     "certificates": [ { "serial", "fingerprint", "notBefore", "notAfter", "revokedAt" } ],
     "consumer": { "composeProject", "composeService", "hostname", "manifestHash",
                   "state", "firstSeen", "lastSeen", "selfReported": true } | null
   }

POST /v1/admin/identities/:id/revoke
-> 204

POST /v1/admin/identities/:id/join-tokens
{ "ttlSeconds": 900 }
-> 201 { "id", "token", "expiresAt" }

The join token is shown once; it is never returned by a read route. ttlSeconds defaults to OPENVAULT_JOIN_TOKEN_TTL (15 minutes) and cannot exceed 24 hours. A create without a project returns 422 validation_error; a duplicate active name returns 409 identity_name_conflict; an unknown or foreign identity returns 404 identity_not_found. Revoke is idempotent and revokes all certificates and unused tokens for the identity. A service identity cannot receive a local credential, OIDC identity, browser session, or CLI family. identity.read allows an auditor; create, revoke, and join-token issuance require an org or owning-project administrator.

Role assignments ​

text
GET /v1/admin/assignments?principalId=01J...&role=secrets.reader&scopeType=project&scopeId=01J...
-> 200 { "assignments": [ {
     "id": "01J...", "principalId": "01J...", "role": "secrets.reader",
     "scopeType": "project", "scopeId": "01J...", "grantedBy": "01J...",
     "createdAt": "2026-11-03T20:00:00.000Z", "expiresAt": null } ] }

POST /v1/admin/assignments
{ "principalId": "01J...", "role": "secrets.reader",
  "scopeType": "project", "scopeId": "01J...", "expiresAt": "2027-01-01T00:00:00.000Z" }
-> 201 { "id": "01J...", "principalId": "01J...", "role": "secrets.reader",
          "scopeType": "project", "scopeId": "01J...", "grantedBy": "01J...",
          "createdAt": "...", "expiresAt": "2027-01-01T00:00:00.000Z" }

DELETE /v1/admin/assignments/:id
-> 204

The assignment create requires assignment.write at the target scope or an ancestor. org scope uses the literal id org; project and object scopes use a ULID. Object scope is limited to secrets.reader and certificates.reader. An expiry must be in the future.

A duplicate tuple returns 409 assignment_conflict. A visible assignment the caller cannot write returns 403 access_denied; an assignment outside the caller's visible scope returns 404 assignment_not_found. Removing the last active user organization administrator returns 409 last_org_admin. A service identity cannot receive a control-plane role; that request returns 422 service_control_plane. Assignment writes are limited to 30 per minute per principal; reads and explanations are limited to 60 per minute per principal. A rejected limit returns 429 RATE_LIMIT_EXCEEDED.

text
GET /v1/admin/access-explain?action=secret.read&scopeType=object&scopeId=01J...
-> 200 {
     "policyVersion": 8,
     "action": "secret.read",
     "scope": { "type": "object", "id": "01J..." },
     "principals": [ {
       "principalId": "01J...", "name": "payments-api", "kind": "service",
       "role": "secrets.reader", "assignmentId": "01J...",
       "scopeType": "object", "scopeId": "01J...",
       "consumer": { "composeProject": "payments", "composeService": "api",
                     "hostname": "api-1", "state": "ok",
                     "lastSeen": "2026-11-03T19:59:00.000Z" } } ] }

The explanation requires assignment.read on the explained resource. A project or object that is missing, soft-deleted, or outside the caller's scope returns 404 resource_not_found. The response lists only principals with an effective permission. policyVersion is read from the same statement as the assignment rows.

GET /v1/meta/roles returns the static action catalog, the nine role definitions, and the current policyVersion. It is public and contains no principals, assignments, or project data.

json
{ "actions": [ { "name": "project.create", "plane": "control",
                  "description": "Create a project" } ],
  "roles": [ { "name": "reader",
               "actions": ["project.read", "secret.list", "certificate.list"],
               "scopes": ["org", "project"] } ],
  "policyVersion": 8 }

The policy layer computes the current principal's capability flags for a secret or certificate. Secret metadata and value responses return them; the UI uses these flags and never derives permissions from client-side role math.

json
{ "capabilities": { "canRead": true, "canWrite": false, "canDelete": false,
                    "canPurge": false, "canManage": false } }

canManage is the assignment.write action at the object's scope; the other flags map to the object's read, write, delete, and purge actions.

Agent authentication ​

text
POST /v1/agent/join
{ "token": "<43-character base64url>",
  "csr": "<PEM certificate request>",
  "consumer": { "composeProject": "payments", "composeService": "api",
                "hostname": "b7c1...", "manifestHash": "sha256:<64 lowercase hex>" } }
-> 200 { "certificatePem", "chainPem", "identityId", "serial", "notAfter" }

POST /v1/agent/renew
{ "csr": "<PEM certificate request>", "consumer": { ... } }
-> 200 { "certificatePem", "chainPem", "identityId", "serial", "notAfter" }

POST /v1/agent/join is the only agent route that does not require a client certificate. It is token-authenticated, rate-limited to 10/minute per source IP, and returns 401 join_failed for an unknown, expired, used, or revoked token. Token failures use one generic body. The token is random; only a hash is stored. The server claims the token atomically with certificate and consumer insertion, so a concurrent second exchange cannot issue another certificate.

The server derives the certificate identity from the token, not from the request. The issued certificate identifies the service and is valid only for client authentication. The request must prove possession of a supported key; invalid requests return 422 validation_error and issue no certificate. The response contains the leaf certificate and CA chain, never a private key.

Every other agent request requires a client certificate chained to OPENVAULT_INTERNAL_CA, an unexpired certificate, a matching stored fingerprint, no revocation, an active principal, and an active project. A failed check returns 401 authentication_required; a database/cache failure returns 503. The check runs on every request, not only at the TLS handshake, and the state cache is bounded to 5 seconds.

The agent listener reserves GET /v1/data/:project/secrets/:name for the data-plane release. The certificate principal's owner_project_id must equal the id of the project named by :project; the production secret handler and consumer heartbeat are delivered with that data-plane work. Revocation removes all certificates and unused tokens in one transaction; no request succeeds more than 5 seconds after the revocation commits, including on an existing keep-alive connection. Renewal is allowed after two thirds of the certificate's actual lifetime and requires a fresh request and key. Only the newest unexpired, unrevoked certificate may renew; older certificates remain valid for data until expiry. Join tokens and workload certificates have bounded retention: tokens for 24 hours after expiry/use; certificates for 30 days after expiry or revocation.

Startup validates that the wrapped CA signing key is present and matches OPENVAULT_INTERNAL_CA; GET /ready then signs and verifies a test certificate with the loaded key.

Meta ​

MethodPathNotes
GET/v1/meta/auth{ local, oidc }
GET/v1/meta/rolesRole and action catalog
GET/v1/meta/capabilitiesGlobal UI gates and project list

Control plane ​

MethodPathAction
GET/v1/admin/projectsproject.read
POST/v1/admin/projectsproject.create
GET/v1/admin/projects/:projectproject.read
PATCH/v1/admin/projects/:projectproject.update
DELETE/v1/admin/projects/:projectproject.delete
GET/v1/admin/identitiesidentity.read
POST/v1/admin/identitiesidentity.create
GET/v1/admin/identities/:ididentity.read
POST/v1/admin/identities/:id/revokeidentity.revoke
POST/v1/admin/identities/:id/join-tokensidentity.join_token
POST/v1/admin/scaffoldassignment.write plus the service checks
GET/v1/admin/assignmentsassignment.read
POST/v1/admin/assignmentsassignment.write
DELETE/v1/admin/assignments/:idassignment.write
GET/v1/admin/access-explainassignment.read
POST/v1/admin/kek/rotatekek.rotate

Project examples:

http
POST /v1/admin/projects { "name": "payments" }
-> 201 { "id": "01J...", "name": "payments",
          "createdAt": "2026-11-04T12:00:00.000Z",
          "updatedAt": "2026-11-04T12:00:00.000Z", "deletedAt": null }

PATCH /v1/admin/projects/01J... { "name": "payments-prod" }
-> 200 { "id": "01J...", "name": "payments-prod", ... }

DELETE /v1/admin/projects/01J...
-> 204

Project names match ^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$. A duplicate active name returns 409 project_name_conflict. A deleted project returns 404 and hides its secrets, certificates, identities, and assignments. There is no project-recovery route in v1.0. Project list items add certificateCount, the number of active certificates in the project; single-project responses keep the existing shape.

Compose scaffold ​

POST /v1/admin/scaffold creates or reuses the project and service identity, grants object-scope secrets.reader only for the listed entries, revokes older unused join tokens, and returns a per-service Compose snippet plus a manifest skeleton.

http
POST /v1/admin/scaffold
{ "project": "payments", "service": "api",
  "entries": ["db-password", "stripe-api-key"] }
-> 200 {
     "project": { "id": "01J...", "name": "payments", ... },
     "identity": { "id": "01J...", "name": "api", "projectId": "01J...",
                    "status": "active", "createdAt": "..." },
     "assignments": [ { "name": "db-password",
                        "assignment": { "role": "secrets.reader",
                          "scopeType": "object", "scopeId": "01J...", ... } } ],
     "joinToken": { "id": "01J...", "token": "<43 chars>", "expiresAt": "..." },
     "compose": "services:\n  openvault-agent-api:\n...",
     "manifest": "project: \"payments\"\n...",
     "manifestFile": "openvault.${COMPOSE_PROJECT_NAME}.api.manifest.yaml"
   }

The token is returned once and never appears in compose or manifest. A project with entries must already exist and contain those secrets. A new project can be created by sending an empty entry list. Re-running is idempotent for the project, identity, and live assignments and issues a fresh token. If the reused identity already has a broader live grant, the route returns 409 identity_scope_conflict. The service name uses ^[a-z0-9][a-z0-9_-]{0,62}$ so it is safe in Compose paths and YAML. The route is rate limited to 5 requests per minute per principal.

Consumer heartbeat and fetch headers ​

POST /v1/agent/consumer accepts { "state": "ok" | "degraded" | "failed" } over mTLS and updates consumers.last_seen and state. A state change writes one consumer.update audit event; a last_seen-only update does not.

An agent secret fetch may send:

HeaderValuesMeaning
X-OpenVault-Consumer-Stateok, degraded, failedAggregate state before this entry
X-OpenVault-Entry-Optionaltrue, falseWhether a 404/410 degrades or fails

The server records the state after a successful read. A 404/410 records degraded for an optional entry and failed for a required entry. Invalid or repeated values return 422.

Data plane ​

MethodPathAction
GET/v1/data/:project/secretssecret.list
POST/v1/data/:project/secretssecret.write
GET/v1/data/:project/secrets/:namesecret.read
GET/v1/data/:project/secrets/:name/versionssecret.read
PUT/v1/data/:project/secrets/:namesecret.write
PATCH/v1/data/:project/secrets/:namesecret.write
DELETE/v1/data/:project/secrets/:namesecret.delete
POST/v1/data/:project/secrets/:name/recoversecret.recover
POST/v1/data/:project/secrets/:name/purgesecret.purge
GET/v1/data/:project/secrets/:name/consumerssecret.list
GET/v1/data/:project/certificatescertificate.list
POST/v1/data/:project/certificates/importcertificate.write
POST/v1/data/:project/certificates/generatecertificate.write
GET/v1/data/:project/certificates/:namecertificate.read
GET/v1/data/:project/certificates/:name/private-keycertificate.read_private
PATCH/v1/data/:project/certificates/:namecertificate.write
DELETE/v1/data/:project/certificates/:namecertificate.delete
POST/v1/data/:project/certificates/:name/recovercertificate.recover
POST/v1/data/:project/certificates/:name/purgecertificate.purge

Secret examples:

http
POST /v1/data/payments/secrets
Idempotency-Key: 6f1c2d3e
{ "name": "db-password", "value": "s3cret", "encoding": "utf8",
  "contentType": "text/plain", "tags": { "env": "prod" } }
-> 201 { "id": "01J...", "projectId": "01J...", "name": "db-password",
          "tags": { "env": "prod" }, "contentType": "text/plain", "status": "active",
          "currentVersion": { "id": "01J...", "createdAt": "...", "createdBy": "01J...",
                              "expiresAt": null },
          "version": { "id": "01J...", ... }, "consumers": [],
          "capabilities": { "canRead": true, "canWrite": true, "canDelete": true,
                            "canPurge": true, "canManage": false } }

GET /v1/data/payments/secrets/db-password
-> 200 { "value": "s3cret", "encoding": "utf8", "version": { ... }, ... }

GET /v1/data/payments/secrets?q=db&tagKey=env&tagValue=prod&limit=50
-> 200 { "secrets": [ { "name": "db-password", ... } ], "nextCursor": null }

PUT /v1/data/payments/secrets/db-password
Idempotency-Key: 8a2b
{ "value": "new-value" }
-> 200 { "currentVersion": { ... }, "version": { ... }, ... }

PATCH /v1/data/payments/secrets/db-password
{ "tags": { "env": "prod", "team": "payments" }, "purgeProtected": true }
-> 200 { ... }

DELETE /v1/data/payments/secrets/db-password
-> 204

POST /v1/data/payments/secrets/db-password/recover
-> 200 { "status": "active", ... }

POST /v1/data/payments/secrets/db-password/purge
-> 204

Values are at most 65,536 decoded bytes; empty and whitespace-only values return 422. A replayed Idempotency-Key returns the original written version. Metadata, lists, and errors never contain a value. Purge returns 409 until the retention boundary passes and protection is off, and 503 when OPENVAULT_PURGE_DB_URL is not configured. The full behavior is in the secrets guide.

Certificates ​

http
POST /v1/data/payments/certificates/import
{ "name": "tls-server", "certificatePem": "-----BEGIN CERTIFICATE-----...",
  "chainPem": "-----BEGIN CERTIFICATE-----...",
  "privateKeyPem": "-----BEGIN PRIVATE KEY-----...", "allowNotYetValid": false,
  "tags": { "owner": "payments" } }
-> 201 { "name": "tls-server", "subject": "CN=...", "sans": ["DNS:..."],
          "issuer": "...", "serial": "...", "fingerprint": "<64 lowercase hex>",
          "notBefore": "...", "notAfter": "...", "daysToExpiry": 364,
          "chainLength": 2, "version": { "id": "01J...", ... },
          "certificatePem": "...", "chainPem": "...", "warnings": [],
          "capabilities": { ... } }

POST /v1/data/payments/certificates/generate
{ "name": "tls-server", "subject": "CN=payments.internal",
  "sans": ["DNS:payments.internal"], "keyType": "ec-p256", "validityDays": 365 }
-> 201/200 { ...metadata..., "certificatePem": "...", "chainPem": "", "warnings": [] }

GET /v1/data/payments/certificates/tls-server
-> 200 { ...metadata..., "certificatePem": "...", "chainPem": "..." }

GET /v1/data/payments/certificates/tls-server/private-key
-> 200 { "privateKeyPem": "-----BEGIN PRIVATE KEY-----...", "version": { ... },
          "fingerprint": "...", "name": "tls-server", "capabilities": { ... } }

GET /v1/data/payments/certificates?q=payments&san=DNS:payments.internal&limit=50
-> 200 { "certificates": [ { "name": "tls-server", ... } ], "nextCursor": null }

PATCH /v1/data/payments/certificates/tls-server
{ "tags": { "owner": "payments" }, "purgeProtected": true }
-> 200 { ... }

DELETE /v1/data/payments/certificates/tls-server
-> 204

POST /v1/data/payments/certificates/tls-server/recover
-> 200 { "status": "active", ... }

POST /v1/data/payments/certificates/tls-server/purge
-> 204

Import/generate are create-or-renew by name: a new name returns 201 and an active name returns 200 with a new immutable version. ?version=<ulid> reads a selected version; an expired current version returns 410 unless explicitly selected. Metadata and lists never contain a PEM body or private key. certificate.read returns the public chain; certificate.read_private is required for the private key and returns 403 for a caller with only public read or object-level access. A caller that cannot list certificates and cannot read the object receives 404. A deleted name is reserved until purge; pass expectedVersionId to reject a stale renewal. Purge requires a deleted certificate, passed retention, no protection, and OPENVAULT_PURGE_DB_URL; a deleted project blocks purge. The full behavior is in the certificates guide.

Audit ​

MethodPathAction
GET/v1/audit/eventsaudit.read
GET/v1/audit/verifyaudit.read
GET/v1/audit/exportaudit.read

GET /v1/audit/events accepts from, to, actorId, action, objectType, objectId, result, requestId, projectId, limit (1–500, default 100), and cursor. Results are newest first. objectId requires objectType.

http
GET /v1/audit/events?action=secret.read&result=deny&limit=50
-> 200 { "success": true, "data": { "events": [...], "nextCursor": "..." } }

GET /v1/audit/verify returns ok, checkedRange, firstBrokenEventId, and a stable reason. A broken chain is still HTTP 200 with ok: false. GET /v1/audit/export streams application/x-ndjson in ascending order and records the attempt before the first byte.

Limits are 60 event queries, 10 verifications, and 5 exports per minute per principal. Invalid filters or cursors return 422; a project outside the caller's grant or an organization-only verify returns 403; storage or audit-key failures return 503.

Operational ​

GET /health is provided by the server. GET /ready checks the database, keyring, TLS/CA material, CA signing key loaded at startup (including a sign/verify probe), and audit table.

Status codes ​

CodeMeaning
400Invalid request body or a failed OIDC callback (oidc_callback_failed)
401Missing or invalid credential (authentication_required, invalid_credentials, or join_failed)
403Policy denial for a caller that can list the collection or already knows the object; csrf_invalid, password_change_required, or access_denied
404Unknown or hidden object; a name-addressed denial from a caller that cannot list the collection; identity_not_found; or oidc_not_configured
409Duplicate, last-admin, or renewal_not_due/identity_name_conflict conflict
413Payload too large
422Route validation failure, including an invalid certificate request or consumer metadata
429Rate limited (RATE_LIMIT_EXCEEDED)
500Integrity failure or unexpected error
503Database, audit, CA signing key, or identity provider unavailable (oidc_unavailable)

Released under the Apache-2.0 License