Skip to main content
The credential vault is Crewship’s encrypted store for API keys, OAuth tokens, and secrets. It covers the full lifecycle: CRUD on credentials, live validation (test before save and re-test stored values), zero-downtime rotation with a configurable grace window, an append-only audit timeline, and per-agent assignment. Values are protected at rest with AES-256-GCM and are never returned in API responses.
All credential endpoints require authentication and workspace context unless otherwise noted.

Endpoints

Credential endpoints

Endpoint contract

Each endpoint identifies Auth, Request, Response, and Status. Unless noted, requests and responses are JSON, workspace routes require a session/CLI token plus membership, and secret values are never returned. The shared failures are 401 unauthenticated, 403 unauthorized, 400 invalid input, 404 missing or invisible resource, 409 uniqueness/lifecycle conflict, and 500 unexpected handler/database failure. Test and reveal routes may also return 429. Route gates and status writes are verified in internal/api/router_crews.go, internal/api/credentials_mutate.go, internal/api/credential_rotation.go, and internal/api/credential_fields.go. Auth: Use the role/capability written on the endpoint; read visibility is also enforced for crew-scoped credentials. Request: JSON body and query parameters are shown on the endpoint. Secret values may be sent only to create, rotate, test, or reveal operations. Response: Credential metadata, rotation records, bindings, and field metadata never contain the encrypted credential value; reveal is the sole explicit exception and is separately audited. Status: Success is 200 for reads/updates, 201 for creates, and 204 where a delete section says so. Invalid input is 400, authorization is 401 or 403, invisible resources are 404, conflicts are 409, and rate limits are 429.

Managing Credentials

Explain your credential access

Auth: Session or CLI token with membership in the selected workspace. The credential must pass the same visibility filter as the detail endpoint. Request: Path credential ID and required workspace_id query; no body. Response: credential_id and actions, each with state (allowed, conditional, or denied) and reason. reveal can only be conditional: fresh interactive login, a written reason and a successful audit write are checked by the reveal action. A CLI token receives non_interactive_auth. No secret value is read or returned by this endpoint. Status: 200 for a visible credential, 401 without authentication, 404 for a missing or hidden credential, and 500 if a policy or capability lookup fails. The response is not cached.

Routine dependents

GET /api/v1/credentials/{credentialId}/dependents returns visible, saved routines that statically declare the credential’s type through credentials_required, credential_ref.type, or {{ secrets.<type> }} in supported steps and hooks. Each row has a routine slug, name, type, and resolution: would_resolve if the runtime’s current ACTIVE selection points to this credential, another_credential if a different matching row wins, or unavailable if no row qualifies. The response also includes visibility_limited, dynamic_uses_untracked, and recorded_use: "not_attributed". It never returns secret values. These are configured references and a present-time selection estimate, not a run-level usage record. Agent and script lookups performed dynamically cannot be inferred from the routine definition. Hidden routines are excluded for non-management roles. Selection follows the runtime’s credential_crews grants for any live crew, with an unlinked workspace credential as fallback; legacy crew_id alone does not grant access. crewship credential dependents <name-or-id> shows the same view in the CLI. Core CRUD for the vault — create, read, update, and soft-delete credential records. Secret values are encrypted on write and omitted from every response.

List Credentials

Returns all credentials in the workspace with agent assignment info. Auth: Session or CLI token + workspace membership. Provider accounts are OWNER/ADMIN-only: a PROVIDER_LOGIN row, and an AI_CLI_TOKEN / API_KEY row whose provider is a model provider, is dropped from the list for every lower role (credentialVisibilityFilter in internal/api/credentials_loaders.go), and any per-id route answers 404 for it below OWNER/ADMIN (providerAccountPolicy in internal/api/provider_login_policy.go) — a capability grant for ordinary secrets does not cross this boundary. Delivery to agents is unchanged. Query Parameters: (parseListPagination in internal/api/credentials.go:81) The response carries X-Total-Count, X-Limit and X-Offset describing the window (see Paging under List Crews); the cursor form below carries X-Total-Count and X-Limit too. Ordering is type ASC, created_at DESC, id ASC, so paging with limit/offset is stable across requests. Pass paginate=true to use the opt-in cursor form instead of the legacy array. That form defaults to limit=50, caps limit at 500, orders by created_at DESC, id DESC, and returns { "credentials": [...], "next_cursor": "...", "limit": 50 }; next_cursor is null on the last page. A malformed cursor or a non-positive limit in this mode returns 400. Response: 200 OK

Response Fields


Create Credential

Auth: MANAGER+ role, or a member with the credential.create capability for this workspace. A provider account (type: PROVIDER_LOGIN, or AI_CLI_TOKEN / API_KEY with a model provider) needs OWNER or ADMIN; below that the request is 403 Provider accounts require OWNER or ADMIN, and the credential.create capability does not lift it. Request Body:
The value is encrypted with AES-256-GCM before storage using the format v1:{base64(IV||AuthTag||Ciphertext)}. Response: 201 Created — credential object (without the encrypted value).

Get Credential

Returns credential metadata (never the encrypted value). Response: 200 OK — credential object with crew_ids, agent_names, and mcp_used.

Update Credential

Both methods behave as partial update. Auth: OWNER, ADMIN, or MANAGER role (canRole(role, "update") in internal/api/credentials_mutate.go). A MANAGER cannot reach a provider account at all — the row is 404 for them (see List). Updatable Fields: Note: The status field cannot be updated via this endpoint. Status changes are managed by the credential monitor and OAuth refresh worker. Re-login (PROVIDER_LOGIN with value). Sending a value to a provider login replaces the login in place, keeping the credential’s id and every assignment and binding: the value is split into its parts again (access token, refresh token, id token, account, plan, expiry) the way create splits it, the previous parts and the refresh state (provider_login_refresh) are discarded, and token_expires_at is taken from the new token. type and provider must stay what they are — a request that changes either alongside value is 400 re-login must keep the existing provider and type. This is how a login that reached needs_relogin is brought back without re-granting it.
Changing provider to or from OPENAI_COMPAT requires sending value in the same request, and returns 400 otherwise.For most providers the stored value is an opaque token. For a provider whose upstream comes from the credential it is a {"baseURL", "apiKey", "headers"} object that Crewship splits before delivery. Switching between the two re-interprets bytes that are already stored: one direction leaves a bare key under a loader that expects an object (the credential saves and then silently reaches no agent), the other leaves an object under a loader that expects a key — which would send the base URL and every custom header upstream in an Authorization header. Crewship refuses rather than guess a conversion.
Response: 200 OK — updated credential object.

Delete Credential

Soft-deletes the credential and clears it from all MCP bindings.
Deleting a credential removes it from every MCP binding that references it.
Auth: OWNER or ADMIN role Response: 200 OK

Validating Credentials

Probe a value against the provider’s live API — either an unsaved value before storing it, or an existing stored credential.

Test Credential

Validates a credential value against the provider’s API without storing it. Useful for checking a key before saving. Rate-limited to 60 requests/minute per IP. Auth: Session or CLI token (no workspace context needed) Request Body: (internal/api/credentials_test_endpoint.go:229)
Response: 200 OK with { "valid": bool, "status": int, "error": string | null, "supported": bool }. error carries omitempty, so on a clean probe the key is absent rather than empty or null. The generated spec declares it nullable: true, so a generated client may also admit null; treat absent, "" and null as the same “no error”. Read supported and valid, not the presence of error. supported reports whether this credential shape has a probe, not whether an upstream request was dialled on this route. For OPENAI_COMPAT and for any ENDPOINT_URL it answers true while this route validates only that the URL is well-formed — the reachability dial is withheld here on purpose. See the note under Test Stored Credential below. For type: PROVIDER_LOGIN, an optional mode (api_key or subscription) selects the credential shape; when omitted, Crewship infers it from the value. Only api_key mode uses the provider’s API-key probe.

Test Stored Credential

Re-tests an already-stored credential by decrypting its value server-side and probing the provider. Records an audit event so the detail-sheet timeline reflects the manual check. (internal/api/credentials_test_endpoint.go:253) Auth: OWNER, ADMIN, or MANAGER role (canRole(role, "update")). Rate-limited to 60 requests/minute per IP. Response: 200 OK with { "valid": bool, "status": int, "error": string | null, "supported": bool }. supported reports whether this stored credential has a real upstream probe. Crewship maintains probes for API-key credentials from ANTHROPIC, OPENAI, OPENAI_COMPAT, GOOGLE, OPENROUTER, CURSOR, FACTORY, GITHUB, GITLAB and VERCEL (probeSupportedProviders in internal/api/credentials_test_endpoint.go), plus any credential of type ENDPOINT_URL whatever its provider — for that type the stored value is the target being dialled. A PROVIDER_LOGIN in api_key mode uses its provider’s API-key probe; an Anthropic subscription setup-token retains its OAuth probe. An OpenAI PROVIDER_LOGIN in subscription mode does not: Crewship stores the OAuth access token extracted from a Codex auth.json and reconstructs the login file for the CLI; sending that token as an OpenAI API key tests the wrong credential. Older OpenAI AI_CLI_TOKEN auth blobs are likewise unprobeable. These return supported: false, valid: true under the existing unchecked-result convention, and an explanation in error. This is not proof that the login works; use a controlled agent run. Every other unprobeable provider likewise answers valid: true with supported: false — nothing failed because nothing was attempted. OPENAI_COMPAT and ENDPOINT_URL are actually dialled only here. Their endpoint is operator-supplied, so the dial is withheld on POST /api/v1/credentials/test above — that route is authenticated but carries no workspace or role floor, and dialling a caller-supplied host from it would make the server a blind-SSRF and internal-port-scan oracle. It answers “the endpoint URL is well-formed” and stops. This route is gated on canRole(role, "update") plus workspace visibility, and the URL was already vetted when the credential was created, so the reachability dial happens: the dialer resolves DNS first and refuses link-local and metadata addresses on the concrete IP, redirects are never followed so a 3xx cannot walk the probe onto a host the guard refused, and the probe offers no credential at all — not the stored key, not the custom headers. It asks for a model list, because reachability is the thing an operator cannot otherwise find out, and authentication is not worth turning the Test button into a way to post the stored secret at a baseURL that anyone holding “update” can repoint. Read supported before valid: supported: false means the credential was not checked, which is neither a pass nor a failure. valid retains its original meaning for existing clients.

Refresh Provider Login

Forces a provider login’s access token to be renewed now from the sealed refresh token the server keeps (docs/prd/provider-logins.md §10.4). Only a PROVIDER_LOGIN in subscription mode with a stored refresh_token part — a ChatGPT login — can be refreshed. On success the new access, refresh and id tokens and the expiry are stored, a REFRESH audit event is recorded, and the file is re-rendered into the running containers of every Codex agent the login pays for. On a provider failure the login’s refresh state carries the error and the backoff; after three consecutive failures, or one the provider calls permanent (invalid_grant), refresh.status is needs_relogin and current workspace administrators get an inbox message, even if the original creator has since been demoted. The server also refreshes on its own: 24 h before expiry from the credential monitor, and before a run start with less than 48 h left. Auth: OWNER, ADMIN, or MANAGER role (canRole(role, "update")) plus workspace visibility. Request: No body. Response: 200 OK with { "login": { … } } — the login object after the refresh. 502 Bad Gateway with { "error", "login" } when the provider refused; the login’s refresh.status and refresh.error say why.

Rotation & Audit

Rotate secrets without downtime via an overlap grace window, inspect the append-only audit trail, and manage rotation history.

Audit Timeline

Returns the credential’s append-only audit timeline (USE, ROTATE, TEST, REVOKE, DETECTED, CREATED events). Backs the Audit tab in the detail Sheet. (internal/api/credential_audit.go:276) Auth: OWNER, ADMIN, or MANAGER role (canRole(role, "update")). Audit reveals admin-action IPs, so VIEWER/MEMBER are blocked. Query Parameters: Response: 200 OK — JSON array (newest first).

Rotate Credential

Issues a new value and starts a configurable grace overlap window. The previous encrypted value is preserved on a new credential_rotations row for the grace window, then scrubbed when status transitions to EXPIRED or CANCELLED. (internal/api/credential_rotation.go:77) While the window is open, the previous value is consumed on a 401. A crew sidecar that boots inside the window receives it beside the new value (never in the agent’s environment), and when the provider answers 401 to the new value the sidecar replays the request exactly once with the previous one; the replay’s answer is what the agent sees, so a second 401 surfaces. Nothing else triggers it — a 403, a 429 or a 5xx is not retried, and only the rotated credential’s own previous value is used. The replay is recorded as a network.egress journal entry naming the credential and the rotation. A sidecar that was already running before the rotation is still on the previous value and needs no fallback.
Rotation replaces the live secret. The previous value is retained only for the grace window (then scrubbed), and the old value is scrubbed immediately when the rotation is expired or cancelled.
Auth: OWNER or ADMIN role, or any member holding the credential.rotate capability (layered gate requireRoleOrCapabilityOrForbid — lets an oncall MANAGER/MEMBER rotate a leaked token without blanket vault access) Request Body:
Response: 200 OK
The response is EXPIRED immediately when grace_seconds is 0; otherwise it starts as ACTIVE. cancelled_at is nullable and is set when a rotation is cancelled.

List Rotations

Returns the rotation history for a credential, newest first. Powers the Settings tab in the detail Sheet. (internal/api/credential_rotation.go:209) Auth: Session or CLI token + workspace membership. The same visibility filter as GET /credentials/{id} applies: MANAGER+ see every credential’s history; MEMBER/VIEWER only see WORKSPACE-scoped credentials or CREW-scoped ones belonging to a crew they are a member of. Response: 200 OK — JSON array. Each entry also includes nullable cancelled_at when a rotation was cancelled.
old_value_gone is true once status is EXPIRED or CANCELLED (the old encrypted value has been scrubbed from the row).

Rotation Statuses


Cancel Rotation (End Grace Early)

Ends an ACTIVE grace overlap immediately, scrubbing the stored old value. Idempotent: already-terminal rotations return 200 with the existing status. (internal/api/credential_rotation.go:258)
Cancelling a rotation scrubs the retained old value at once, ending sidecar 401-fallback before the grace window would otherwise elapse. A running sidecar drops its own copy after the next successful credential reconciliation (polled every 60 s). Reconciliation checks the exact rotation ID, so another active rotation cannot keep a cancelled value usable. If the metadata request fails, the boot-delivered expiry still bounds fallback locally.
Auth: OWNER or ADMIN role, or any member holding the credential.rotate capability (same layered gate as rotate — ending a grace window early is part of the rotation lifecycle) Response: 200 OK
For an already-terminal rotation:

Bindings

A credential’s name is the account (github-acme). The slot is the environment variable the agent reads (GH_TOKEN). A binding connects them in one scope:
Two rules govern the whole surface:
  • One slot, one credential, per scope. Enforced by a unique index; a conflicting write is refused with 409, never applied as a replacement.
  • Resolution is agent > crew > workspace. The most specific scope that binds a slot wins, and only that one is delivered.
A credential with no binding is delivered under its own name — the pre-binding behaviour, unchanged.

List Bindings

Query parameters (all optional, all exact-match): scope, crew_id, agent_id, credential_id, slot. Auth: Session or CLI token, workspace context required. Response: 200 OK

Create Binding

Role: OWNER / ADMIN (manage).
Response: 201 Created with the binding.

Delete Binding

Role: OWNER / ADMIN (manage). Response: 204 No Content, or 404 if the binding is not in the caller’s workspace. The row is really deleted — it is the slot claim, so anything that merely hid it would keep the slot occupied and 409 every later write. Bindings also cascade with their scope: deleting a crew, an agent, or the credential itself removes the bindings that pointed at it.

Resolve Slots for an Agent

Reports the slot map the agent will actually boot with, after resolution. Read-only, and values are never included. Response: 200 OK
source, most specific first: agent_grant (an explicit per-agent assignment), agent_binding, crew_binding, workspace_binding, crew_link (no binding — delivered under the credential’s own name). The response may also contain a warnings array when a credential was renamed, normalised, or could not be assigned a deliverable slot; warnings never contain secret values.

Custom Fields

Custom fields extend a credential with named parts such as a non-secret region or an additional secret. Field keys are lower snake case, start with a letter, and are limited to 64 characters. Secret field values are encrypted and are never returned; non-secret values are returned as value.

List Custom Fields

Auth: authenticated workspace member with visibility of the credential. Response: 200 OK

Add Custom Field

Auth: MANAGER+ role.
is_secret defaults to true when omitted. value is required and is capped at 64 KiB. The response is 201 Created with the field object. A missing or invisible credential returns 404; invalid keys/values or a duplicate key returns 400.

Update Custom Field

PUT /api/v1/credentials/{credentialId}/fields/{fieldKey} Auth: Authenticated workspace member with the create capability (the same write tier as updating the credential). Credential visibility is enforced against the current workspace; an invisible credential is reported as 404. Request: Path parameters are credentialId and fieldKey. JSON body:
value is required and capped at 64 KiB. is_secret and ordinal are optional; omitted is_secret preserves the field’s current classification, and the path key cannot be renamed. Response: 200 OK with the updated field object. Secret field values are omitted (value is null); non-secret values are returned. Statuses: 400 invalid JSON, key, value, or ordinal; 401 unauthenticated; 403 insufficient role/capability; 404 credential or field not found; 500 unexpected database or encryption failure.

Delete Custom Field

DELETE /api/v1/credentials/{credentialId}/fields/{fieldKey} Auth: Authenticated workspace member with the create capability. Credential visibility is enforced against the current workspace. Request: No body. credentialId and fieldKey are required path parameters. Response: 200 OK with { "success": true }. Statuses: 401 unauthenticated; 403 insufficient role/capability; 404 credential or field not found; 500 unexpected database failure.

Lookups & Assignment

Helper lookups and the cross-reference to per-agent credential assignment.

Default Environment Variable

Returns the default environment variable name for a given provider (e.g., GH_TOKEN for GITHUB), and whether that provider can be probed. Recognised providers: GITHUB → GH_TOKEN, GITLAB → GITLAB_TOKEN, VERCEL → VERCEL_TOKEN, AWS → AWS_ACCESS_KEY_ID, KUBERNETES → KUBECONFIG; any other provider returns an empty string. The optional type parameter affects testable only: a credential of type ENDPOINT_URL is probeable whatever its provider, because the stored value is itself the target being dialled. env_var is derived from provider alone. Auth: Session or CLI token (no workspace context needed) Response: 200 OK
testable lets a client decide whether to offer a “Test” action without keeping its own list of probeable providers — the dashboard’s Add Credential form reads it for exactly that. The same flag rides on each credential in List and Get responses, so a detail view needs no extra call.

Agent Credential Assignment

See the Agents page for credential assignment endpoints:

Provider logins — sign in with a device code

A subscription login for a model provider can be created without pasting a file: the server drives the provider’s device-code authorization, the person enters a short code in the provider’s browser page, and the resulting login is stored as a PROVIDER_LOGIN credential owned by the caller. The server — not the client — polls the provider, so a closed tab or a CLI timeout does not lose the sign-in, and a pending sign-in survives a server restart. Only OPENAI (ChatGPT plans, delivered to the Codex adapter as a rendered auth.json) is offered today. CLI: crewship credential login --provider OPENAI.

Start a device-code sign-in

Auth: workspace OWNER or ADMIN. Provider-account administration has this role floor even when a lower role has the credential.create capability. Request body:
Response: 201 Created
Show user_code and verification_url to the person, then poll the status endpoint every interval_s seconds until status leaves pending. The code is valid for 15 minutes.

Device-code sign-in status

Auth: required. Only the user who started the sign-in can read it; any other id answers 404. Response: 200 OK
The created credential is named "ChatGPT <plan> · <email>" from the token’s plan claim and the account’s email, carries the whole login as its value (the refresh token is sealed on the server and replaced by a placeholder in every delivered file), and is otherwise exactly the row Create Credential would have produced — including its CREATED audit event.

Provider pools (account groups)

A pool — “account group” in the Credentials page — is a named set of provider accounts of one provider and one billing mode, in priority order, so that when one seat is unavailable another can take its turn. The API and CLI call it a pool; the UI calls it an account group; they are the same object (internal/api/provider_pool.go, internal/providerpool). A pool is a definition only. Creating one does not bind it to any agent, grant its members to anyone, decrypt a token or contact a provider; runtime selection is not wired to it yet. Every pool route, reads included, requires OWNER or ADMIN; a scoped CLI token additionally needs credentials:write for create, update and delete (reads need no scope). Capabilities for ordinary secrets do not grant pool access. Secret values are never returned. The CLI is crewship credential pool … (reference). Revisions and ETag. Each pool carries a revision that starts at 1 and increases by one on every PUT. GET one pool returns it as a strong ETag ("2"); PUT and DELETE require it back as If-Match and answer 412 when the pool has moved on, 428 when the header is missing, and 400 when it is not one quoted integer (wildcards, weak tags and lists are refused).
A pool member must be a credential of this workspace, and members must all carry the pool’s provider and mode: PROVIDER_LOGIN rows, or legacy API_KEY rows (read as api_key). Unknown and foreign-tenant ids fail the same way (400), so the existence of a credential in another tenant is not disclosed. Accounts owned by different people can only be pooled with allow_cross_owner: true; a member with no recorded owner cannot be pooled at all.

List provider pools

Auth: OWNER or ADMIN. Query Parameters: Response: 200 OK — up to 100 pools without their member lists.
next_cursor is null on the last page. Retired pools are not listed.

Create provider pool

Auth: OWNER or ADMIN. Request Body: unknown properties are rejected (400); the body limit is 64 KiB.
Response: 201 Created with ETag: "1" — the pool as Get returns it, revision 1, members echoed. An unavailable member (a seat waiting on re-login) does not fail creation; a malformed or mixed-owner set does. The create is audited (provider_pool.create) with the member count and consent flag only.

Get provider pool

Auth: OWNER or ADMIN. Response: 200 OK with ETag: "<revision>" — the definition read in one snapshot, including members whose seat is revoked or expired, so an administrator can diagnose the set. It is not an availability probe.

Update provider pool

PUT /api/v1/provider-logins/pools/{poolId} Authentication: workspace OWNER/ADMIN; scoped CLI tokens also require credentials:write. Ordinary-secret capabilities do not grant pool access. Request headers: If-Match: "1" using the current ETag from Get. Wildcards, weak tags and tag lists are not accepted. Request body: complete replacement definition, not a patch:
Name is 1–200 characters; membership is 1–100 unique same-workspace IDs. Provider and authentication mode cannot change. Unknown properties are rejected, the body limit is 64 KiB, and cross-owner consent must be explicitly resubmitted. Response: 204, no body, with the new ETag. Retained members keep selection history; this does not assign credentials or enable runtime failover. Status codes: 400 invalid input, 401 unauthenticated, 403 forbidden, 404 missing/foreign/retired pool, 409 name conflict, 412 stale revision, 428 missing If-Match, 500 internal failure. Errors use {"error":"…"}. Inspect the new definition before retrying 412. Audit uses the existing best-effort settings audit; it is not transactionally coupled to the definition update.

Delete provider pool

DELETE /api/v1/provider-logins/pools/{poolId} Authentication: workspace OWNER/ADMIN, with credentials:write for scoped CLI tokens. Request headers: current If-Match: "2"; no request body. Response: 204, no body. Soft deletion hides the pool from list/detail and rejects future selection. Provider accounts and membership history remain; the name stays reserved. No upstream account is revoked. Status codes: 400 invalid revision, 401 unauthenticated, 403 forbidden, 404 missing/foreign/already-retired pool, 412 stale revision, 428 missing If-Match, 500 internal failure. Errors use {"error":"…"}. Retirement is audited through the same best-effort administrative audit. This increment manages definitions only; runtime bindings and active-execution protection are not enabled yet.

Reference

Enums and value sets used across the credential endpoints above.

Credential Types

The closed type enum (internal/api/credentials_types.go, enforced by both Create and Update):

Providers

provider is a free-form string. The values below are the ones with a live validation probe in the Test / Test Stored endpoints (internal/api/credentials_test_endpoint.go); any other provider value (including NONE) is accepted and stored, but Test returns valid: true with "No validation available for this provider".

Statuses

Revoking an LLM/provider token stops proxy injection within ~60s. A revoked credential is filtered out of every fresh delivery path immediately, and each agent’s sidecar also holds an in-memory snapshot of its provider tokens (loaded at container start). The sidecar reconciles that snapshot against the live set on a ~60s interval and drops any credential the server no longer lists (revoked or deleted), so a revoked provider key stops being injected into new outbound LLM requests within one interval — without waiting for the container to restart.File-based secrets are removed on revoke, too. File-based secrets written to /secrets/{agent-slug}/ (DB passwords, GH_TOKEN, SSH keys, certs) are materialized on disk at container start. On revoke (deleting a credential), the server reaches into every running crew container that mounted the credential as a file and removes the secret file(s) — exec’d inside the container as the agent UID (1001), the only principal that can unlink inside the agent’s 0700 secrets directory (the sidecar, UID 1002, cannot). This runs synchronously with the delete, so a successful revoke has already removed the files from live containers.
Removal is best-effort: a container that isn’t running is skipped (nothing to remove, and the revoked credential won’t be re-materialized on the next boot since it’s filtered from every delivery path). The .env hint-map keeps a now-inert reference to the removed file until the next container boot — harmless, since the file it points at is gone. If an exec fails transiently, recreate the crew container to force a clean rewrite.

Reveal

Reveal is the only endpoint in Crewship that returns a stored secret in plaintext. Every other read path omits values entirely.
Rotate instead, almost always. Most reasons to reveal a secret are really reasons to put a new secret somewhere. POST /credentials/{id}/rotate issues a new value, shows it once, and lets the old one drain through a grace window — no credential that is currently in use is ever exposed. Reveal exists for the cases where you genuinely need the value that is already deployed.
A reveal succeeds only when all of the following hold. Each is checked independently and each fails closed, so breaking one does not get you past the others.

Agents, API tokens and CI can never reveal

The endpoint requires an interactive, revocable user session. A CLI token (crewship_cli_… / crewship_admin_…), an X-Internal-Token, a sidecar request, and any future auth method that does not explicitly opt in are all refused with 403. This is a gate on the credential shape, not on identity. An agent’s request carries a real user’s id and role — that is how delegation works — so “who is this?” cannot separate a person from a container. “What did they present?” can. An agent that needs a secret gets it through the normal delivery paths (env var, /secrets mount) or asks a human via Keeper escalation; it never reads one back out of the vault. Because of this, crewship credential reveal asks for your password and signs you in interactively rather than using the stored CLI token.

Capability, not role

credentials:reveal is granted per person on the membership and is what the gate checks; the role floor only says who may hold it. Since #2462 the admin preset includes it, and an OWNER or ADMIN whose membership has no explicit capability set holds it by default (BundleAdmin in internal/api/capabilities.go, the role fallback in capabilities_check.go). An explicit set is always authoritative: revoking credentials:reveal from an ADMIN removes it, and a MANAGER holds it only when granted. See Member capabilities. Recommended for corporate deployments: revoke it from the admin team and grant it to two people.

A fresh sign-in, on your own password

The value is disclosed only to a session created in the last 5 minutes (internal/api/credentials_reveal_freshness.go). The clock is the server-side session creation time, not the JWT refresh or last activity, so a stolen session that is kept alive does not earn a reveal window; the CLI satisfies this by signing you in for the call. An account still on the seed default password is refused as well (default_password) — change it in account settings and sign in again.

Reveal Credential

Auth: Interactive session only + workspace membership. CLI tokens, internal tokens and sidecars are refused. Rate limit: Dedicated per-IP bucket (http.cred_reveal_per_min, default 3/min) — far tighter than the general API bucket, and unlike it, authenticated CLI callers are not exempt. Request Body:
Response: 200 OK with Cache-Control: no-store
journal_entry_id anchors the disclosure to the tamper-evident audit chain — quote it in an incident report and it can be verified. Errors:

The audit write is a precondition

The reveal is recorded in internal/journal, which is an HMAC hash-chain with signed compaction checkpoints, before the value is read out of the row. If that write fails, the request returns 500 and no value is returned. There is no “return it now, audit later” path. The flat credential_audit timeline also gets a REVEAL row so the disclosure appears in the credential’s detail Sheet next to every other event on it. That table has no hash chain — a row can be deleted without trace — which is exactly why it is not the authoritative record. What the journal payload contains: actor, credential id and name, classification, reason, actor role, client IP. What it never contains: the value, or any hash or fingerprint of it. A digest looks like a harmless identifier and is not — journal rows are readable by anyone with journal access and travel in every backup, and a digest of a short secret is crackable offline in seconds. Recording one would turn the audit log into a second copy of the vault.

Classification

Every credential carries a sensitivity. It is the first thing the reveal gate consults and the only one that no role can override. SEALED has no escape hatch by design. Break-glass for a sealed credential is rotation: mint a new value, let the old one drain through its grace window. An escape hatch that exists is an escape hatch that gets used. Existing credentials default to STANDARD, which is safe only because the workspace reveal switch defaults to off.
RESTRICTED behaves identically to STANDARD today. The four-eyes second-approver flow that will distinguish them is deferred; classifying now still pays off, because that gate will key off this field.

Set Classification

Auth: Session or CLI token + workspace membership. Raising requires MANAGER+; lowering requires OWNER or ADMIN. The asymmetry is deliberate. Raising only ever removes reach, so it needs no ceremony — and if tightening is annoying, people stop tightening. Lowering hands out a key that did not exist a moment earlier, so it is written to the audit chain first and fails closed if that write fails. Request Body:
Response: 200 OK
A no-op change (same value) returns 200 and is not journaled. Errors: 400 unknown class · 403 lowering without OWNER/ADMIN · 404 not in this workspace · 500 audit write failed (the classification is unchanged)

Workspace Reveal Policy

Reveal is off for every workspace until an OWNER turns it on, including freshly created ones. A default of “on” would make a new tenant’s security posture depend on someone remembering to lock it.

Read Reveal Policy

Auth: Session or CLI token + MANAGER or above. MEMBER and VIEWER get 403 — whether a tenant has reveal enabled is target-selection information. A MANAGER reads it because they need to know the rules they work under, but cannot change it. Response: 200 OK

Set Reveal Policy

Auth: Session or CLI token + OWNER only. Journaled as a precondition: if the chained write fails, the switch does not move. An attacker who can wedge the audit log must not be able to open a tenant’s reveal surface unobserved. Both directions are recorded. Request Body:
Response: 200 OK
Enabling the switch does not grant anyone reveal on its own — each person still needs credentials:reveal on their membership. Errors: 400 body is not {"enabled": bool} · 403 caller is not OWNER · 500 audit write failed (switch unchanged)