# Manage Secrets

Polyant stores per-instance secrets (API keys, tokens, bucket names) in the `instance_secrets` table, encrypted at rest with **AES-256-GCM**. Values are never returned by the API - only the list of configured keys.

This guide walks through listing, creating, updating, and deleting secrets, plus rotating the master encryption key.

> **Managed services.** With managed services the keys of the AI and speech providers an agent chats, embeds, transcribes and speaks with are not secrets: they are held by named [provider connections](https://docs.polyant.ai/admin-panel/provider-connections), bound to the agent's roles. The secret routes refuse to write them. Everything else on this page - tool keys, LangSmith, object storage, inbound-auth keys - works the same in both builds.

## Prerequisites

- An engine running with `ENCRYPTION_KEY` set in the environment.
  - The key MUST be **64 hex characters** (32 bytes, AES-256). Generate one with `openssl rand -hex 32`.
  - Changing this value invalidates every existing encrypted row - plan ahead (see [Rotate the master key](#rotate-the-master-key) below).
- An existing instance, identified by its `slug`.
- A valid session cookie or `Authorization: Bearer <token>` header (the admin API is authenticated).

## Step 1 - List the configured secret keys

```bash
curl -s http://localhost:4000/api/instances/my-assistant/secrets \
  -H "Cookie: authjs.session-token=$TOKEN" | jq
```

Response (well-known keys are always listed; `configured: false` means the row does not exist yet):

```json
{
  "secrets": [
    { "key": "openai_api_key", "configured": true },
    { "key": "anthropic_api_key", "configured": false },
    { "key": "langsmith_api_key", "configured": false }
  ]
}
```

Keys outside the well-known set (e.g. `hubspot_api_key`, `serpapi_api_key`, `slack_*`) are NOT in the `SECRET_KEYS` enum, so they appear in this listing only **after** they have been explicitly written via the PUT below.

With managed services the listing no longer offers the provider keys that connections hold; a copy left in the secrets by the upgrade is still listed, so it can be deleted.

Values are NEVER returned by this endpoint. To verify a value, your only options are the consumers (e.g. send a Playground message and see the LLM respond) or query the DB directly with `decrypt()` from the engine.

## Step 2 - Set one or more secrets

The PUT endpoint accepts a batch payload. Use it to add or update keys in a single call.

```bash
curl -s -X PUT http://localhost:4000/api/instances/my-assistant/secrets \
  -H "Content-Type: application/json" \
  -H "Cookie: authjs.session-token=$TOKEN" \
  -d '{
    "secrets": [
      { "key": "openai_api_key",    "value": "sk-..." },
      { "key": "langsmith_api_key", "value": "ls_..." }
    ]
  }'
```

**Key naming**: well-known keys are all **lowercase with underscores**. The recognised set (declared in `secrets.store.ts`) is:

```
openai_api_key, anthropic_api_key, nebius_api_key, bedrock_api_key,
aws_provider_access_key_id, aws_provider_secret_access_key, aws_provider_region,
langsmith_api_key, auth_api_key, tavily_api_key, github_token,
s3_bucket_name, aws_region, http_api_key, deepgram_api_key
```

With managed services a batch naming a provider key (`openai_api_key`, `anthropic_api_key`, `nebius_api_key`, `bedrock_api_key`, the three `aws_provider_*` keys, `deepgram_api_key`, and the TensorX, Scaleway, Regolo.ai, ElevenLabs and Typesafe keys) is refused whole with `400`, and nothing is written: the message points to the [connection routes](https://docs.polyant.ai/admin-panel/provider-connections#binding-an-agents-roles), because a key stored there would be read by nothing.

The bucket's access keys, `aws_access_key_id` and `aws_secret_access_key`, are
written the same way. In the panel the bucket, its region and both keys are typed
in the agent's **Advanced** section, in the card that holds the **Store
attachments** switch.

> **`s3_endpoint` is gone.** It was handed straight to the S3 client so an agent could write to MinIO or Cloudflare R2 - which made an agent secret into a request-forgery primitive: whoever could write an agent's secrets could point every upload and download, file contents included, at any host, the deployment's private network included. The key is ignored and the stored values were deleted on upgrade, rather than left inert to read as configuration still in force. Bringing S3-compatible storage back means a deployment-tier allow-list of endpoints, not this key.


> **The AWS provider keys carry a `_provider_` segment on purpose** (open-source build). `aws_provider_access_key_id` and friends feed the AI provider - Bedrock chat and embeddings, AWS Transcribe. They are deliberately a separate namespace from the generic `aws_*` keys that reach object storage (`aws_access_key_id`, `aws_secret_access_key`, `aws_region`), so the provider and the agent's bucket can live in different AWS accounts. A key written as `aws_access_key_id` is read by storage, never by a model provider.

You can also store custom keys (e.g. for skill-specific credentials) using the same endpoint - they will appear in the listing alongside the well-known ones.

## Where each key comes from

Every well-known key is a credential issued by somebody else. This is where you get it, and what it looks like when you have it. With managed services the provider keys in this table go into a [provider connection](https://docs.polyant.ai/admin-panel/provider-connections) instead, under the connection's own field names (`api_key`; for Bedrock `api_key` or `access_key_id` and `secret_access_key`, plus `region`); where each comes from does not change.

| Key | Provider console | Notes |
|---|---|---|
| `openai_api_key` | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) → *Create new secret key* | `sk-…`, displayed once. Keys are scoped to a project: a key from the wrong project authenticates and then cannot see your models. |
| `anthropic_api_key` | [console.anthropic.com](https://console.anthropic.com) → *Settings* → *API keys* | `sk-ant-…`, displayed once. |
| `nebius_api_key` | the Nebius Token Factory console → *API keys* ([docs.tokenfactory.nebius.com](https://docs.tokenfactory.nebius.com)) | The provider is OpenAI-compatible; the key is Nebius's own, not an OpenAI one. |
| `tensorx_api_key`, `scaleway_api_key`, `regolo_api_key` | the TensorX, Scaleway and Regolo.ai consoles | **Managed services.** Keys for the three EU providers the managed-services build registers, typed into a provider connection. Scaleway's is an IAM secret key for Generative APIs. |
| `bedrock_api_key` | [Amazon Bedrock console](https://console.aws.amazon.com/bedrock) → *API keys* | A bearer token, and the simplest Bedrock path - it skips SigV4 signing entirely. AWS issues **short-term** keys (up to 12 hours, inheriting your IAM permissions) and **long-term** ones (to an expiry date, creating an IAM user); for a long-lived deployment use a long-term key or the SigV4 credentials below. |
| `aws_provider_access_key_id`, `aws_provider_secret_access_key`, `aws_provider_region` | IAM console → your user → *Security credentials* → *Access keys* | The SigV4 alternative to the key above, used for Bedrock chat, Bedrock embeddings and AWS Transcribe. The IAM principal needs `bedrock:InvokeModel` (and `bedrock:InvokeModelWithResponseStream` for streaming) on the models you intend to call, in that region. Model access must also be **requested** in the Bedrock console per model - a fully correct key returns AccessDenied until it is. |
| `langsmith_api_key` | [smith.langchain.com](https://smith.langchain.com) → *Settings* → *API keys* | `lsv2_…`. Only enables tracing; nothing depends on it. See [Observe with LangSmith](https://docs.polyant.ai/how-to/observe-with-langsmith). |
| `tavily_api_key` | [app.tavily.com](https://app.tavily.com) → *API keys* | `tvly-…`. Needed by the web-search tool and by nothing else. Set on the agent only. |
| `github_token` | [github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens) (fine-grained) or *Developer settings* → *Tokens (classic)* | Read access to the repositories the agent should clone. A fine-grained token must name those repositories explicitly - the default is none. Set on the agent only. |
| `deepgram_api_key` | [console.deepgram.com](https://console.deepgram.com) → *API keys* | Transcribes inbound voice notes on Telegram and WhatsApp. Without it, audio is ignored rather than failing. With managed services it goes into a Deepgram connection, which also serves the voice channel's speech recognition and turn detection. |
| ElevenLabs and Typesafe keys | each provider's API keys page | **Managed services.** For the [voice channel](https://docs.polyant.ai/admin-panel/channels#voice-phone-calls): ElevenLabs speaks and transcribes calls, Typesafe Jev classifies for the call's judges. Typed into a provider connection; there is no secret for them. |
| `s3_bucket_name`, `aws_region` | not credentials | **This agent's** object storage: the bucket stored attachments go to, and its region. The extra plugin's `extra:fileUpload` tool reads the same keys. Required as a pair. The region is not resolved from the environment; with managed services both can come from the organization. |
| `aws_access_key_id`, `aws_secret_access_key` | IAM console → your user → *Security credentials* → *Access keys* | The credentials for that bucket. The principal needs `s3:PutObject` and `s3:GetObject` on `arn:aws:s3:::<bucket>/*`. Both are required: exactly one is a configuration error, and with neither nothing is stored. The deployment's own AWS identity is never used in their place. A separate namespace from `aws_provider_*` on purpose, so storage and the Bedrock provider can live in different accounts. |
| `hubspot_api_key` | HubSpot → *Settings* → *Integrations* → *Private Apps* → the app's *Auth* tab | `pat-…`. Used by the HubSpot **tools**, which arrive as a [plugin](https://docs.polyant.ai/how-to/connect-hubspot), and unrelated to the [HubSpot channel](https://docs.polyant.ai/admin-panel/channels#hubspot), which keeps its own credentials on the channel. Not in the well-known set: it appears in the listing only once written. |
| `auth_api_key`, `http_api_key` | **minted by you, for this agent** | These are the only two that come from nowhere else: they authenticate an inbound caller to *this* agent (the OpenAI-compatible endpoint, and the HTTP channel). Generate a random value - `openssl rand -hex 32` - and hand it to the caller. This is also why neither can be shared at organization level. |

> **Two failure shapes worth expecting.** A key pasted with surrounding whitespace or a trailing newline is a different string and authenticates as nothing; and most of these are shown once at creation, so the recovery for a lost value is a new key, never a lookup. Nothing in Polyant can read a stored value back.

## Step 3 - Delete a secret

```bash
curl -s -X DELETE http://localhost:4000/api/instances/my-assistant/secrets/openai_api_key \
  -H "Cookie: authjs.session-token=$TOKEN"
```

The `:key` path segment is the lowercase secret name. There is no soft-delete: the row is removed immediately and any consumer relying on it (LLM provider, S3 client, etc.) will fail until you set it again.

## What is NOT in instance_secrets

The following live in **different tables** and are managed by separate endpoints - do not try to store them under `/secrets`:

| Credential | Where it lives | Endpoint |
|---|---|---|
| Telegram bot token | `instance_channels` (type=telegram) | `PUT /api/instances/:slug/channels/telegram` |
| Slack bot token (`xoxb-`) and signing secret | `instance_channels` (type=slack) | `PUT /api/instances/:slug/channels/slack` |
| Twilio Account SID / Auth Token / WhatsApp From number | `instance_channels` (type=whatsapp) | `PUT /api/instances/:slug/channels/whatsapp` |
| Skill-specific env vars | `instance_skill_env` | `PUT /api/instances/:slug/skills/:slug/env` |

See [Connect a channel](https://docs.polyant.ai/getting-started/connect-a-channel) for the channel-specific flow.

## Organization-level shared secrets

> **Managed services.**

An organization can hold secrets of its own, shared by every agent in it, through `GET`/`PUT`/`DELETE /api/organizations/:orgSlug/secrets`. The panel has no page that writes them; an agent's settings show a value it inherits this way.

They use the same table shape, the same AES-256-GCM envelope, and the same key vocabulary as an agent's own secrets, so an org-level key and an agent-level key name the same thing.

**Resolution is agent-first.** An organization's secret is the *fallback* for any agent that has none of its own under that key - so no existing agent's behaviour changes the day its organization gets one too.

Reading and writing them needs **`org.secret:read` / `org.secret:write`** - Admin and above. Deliberately not the agent-level `agent.secret:*`, which a Member holds: gating the tenant's shared credentials on one agent's rights would open them to every member.

### Only a few keys can be shared

The fallback applies through an explicit **allowlist**: the LangSmith key, and the S3 bucket name with its region. The organization endpoint refuses any other key with `400`, naming it, and nothing in the batch is written. The bucket's access keys (`aws_access_key_id`, `aws_secret_access_key`) are not on the list: an organization can hold the bucket and its region for its agents, but each agent sets its own keys.

The credentials of tools belong to the agent that enables them. `tavily_api_key` and `github_token` were shareable until 1.3.4-ee: since then an organization cannot hold them and agents no longer inherit them. The upgrade (migration `0175`) deleted the values organizations held for these two keys and copied nothing to the agents, so an agent that relied on one needs its own key, typed in the tool's panel.

Provider keys are not shared this way any more. The organization endpoint refuses them like the agent endpoint does, because an organization shares a provider credential by creating a [provider connection](https://docs.polyant.ai/admin-panel/provider-connections) and lending it to workspaces. Copies left by the upgrade are read by nothing and can be deleted with `DELETE /api/organizations/:orgSlug/secrets/:key`.

`auth_api_key` and `http_api_key` are deliberately **absent** too, and refused the same way rather than accepted as a value that would be silently ignored. Those two authenticate an inbound *caller* to one specific agent: letting them fall back from the organization would let a single org-level key authenticate every agent in it, voiding the per-agent `authEnabled` model.

An allowlist rather than a denylist, on purpose - with a denylist, a newly added inbound-auth key would ride the fallback by default until someone noticed.

## Rotate the master key

The `ENCRYPTION_KEY` env var is the single point of failure for all encrypted data. Rotating it is destructive - you must re-enter every secret afterwards.

1. **Stop the engine** so no readers/writers race the rotation.
2. **Export every instance** (`GET /api/instances/:slug/export`). The bundle does NOT include secret values, only the list of which keys were configured - that is exactly what you need to know what to re-enter.
3. **Generate a new key** with `openssl rand -hex 32` and update `ENCRYPTION_KEY` in your environment.
4. **Wipe the old rows**: `DELETE FROM instance_secrets;` (and, if you also rotate channel encryption, `DELETE FROM instance_channels;`). The old ciphertexts cannot be read with the new key, so leaving them creates noise on every read.
5. **Start the engine** and re-enter the secrets via the PUT endpoint (or the admin panel: provider keys in the agent's Model section, tool keys in the tool's panel). With managed services provider connections are encrypted with the same key: rotate each connection with its key again (see [Provider connections and roles](https://docs.polyant.ai/admin-panel/provider-connections#connections)).
6. **Rotate the upstream credentials too**: anyone who had the old `ENCRYPTION_KEY` could have decrypted the old values, so treat them as compromised.

## Verification

After running Step 2 you should see:

```bash
curl -s http://localhost:4000/api/instances/my-assistant/secrets | jq '.secrets[] | select(.configured)'
```

- The `openai_api_key` and `langsmith_api_key` entries return `configured: true`.
- In the agent's **Model** section, the OpenAI key field shows **Configured** instead of an empty input (open-source build; with managed services the section names the connection bound to each task).
- A test message via the Playground or `POST /v1/chat/completions` succeeds without a "missing API key" error.

## See also

- [Connect a channel](https://docs.polyant.ai/getting-started/connect-a-channel) - for Telegram / Slack / WhatsApp credentials
- [Export and import an instance](https://docs.polyant.ai/how-to/export-import-instance) - bundles list secret keys (without values)
- [Observe with LangSmith](https://docs.polyant.ai/how-to/observe-with-langsmith) - uses `langsmith_api_key`
- [Roles and permissions](https://docs.polyant.ai/reference/roles#two-tiers-of-permission) - why the org and agent secret keys are separate
