> ## Documentation Index
> Fetch the complete documentation index at: https://developers.telnyx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Per-Caller Credentials

> Give every conversation its own MCP bearer token or tool webhook credential, delivered encrypted through the dynamic variables webhook.

An MCP server or webhook tool normally authenticates with one static credential, stored as an [integration secret](/docs/inference/ai-assistants/integrations) and referenced from the assistant configuration. Every conversation uses the same one.

That does not work when the credential belongs to the *end user*. If each caller has their own bearer token for an MCP server, a single static credential either over-shares — one token that can reach everyone's data — or cannot be used at all.

Encrypted dynamic variables solve this. The [dynamic variables webhook](/docs/inference/ai-assistants/dynamic-variables) returns a credential encrypted with a key only the account holds, and Telnyx decrypts it at the moment it authenticates that one conversation's request.

***

## How it works

1. **Store a key.** Generate a 256-bit key and save it as an integration secret.
2. **Return a ciphertext.** The dynamic variables webhook returns the caller's credential in a new `encrypted_dynamic_variables` section, encrypted with that key.
3. **Reference it.** The assistant configuration points at the variable and the key with `{{variable | encryption_secret_ref}}`.
4. **Telnyx decrypts it** only where a credential is actually needed, for that conversation only.

Decrypted values exist in memory at the moment of use. They are never stored, never written to a log, and never visible to the model.

***

## 1. Store the encryption key

Generate 32 random bytes and store them base64url-encoded as an integration secret. The secret's identifier is what the configuration references.

```python theme={null}
import base64, os

print(base64.urlsafe_b64encode(os.urandom(32)).decode())
```

Store it with `POST /integration_secrets`. The `identifier` is what the configuration references:

```json theme={null}
{
  "identifier": "mcp_enc_key",
  "type": "bearer",
  "token": "<base64url-encoded key>"
}
```

Padding is optional — a value with or without trailing `=` is accepted.

Keep the raw key: the webhook needs it to encrypt.

***

## 2. Return encrypted variables from the webhook

The dynamic variables webhook response gains an optional `encrypted_dynamic_variables` section, a sibling of `dynamic_variables`:

```json theme={null}
{
  "dynamic_variables": {
    "customer_name": "Ada"
  },
  "encrypted_dynamic_variables": {
    "mcp_token": "q1zqGJeXi0…base64url…"
  }
}
```

| Property        | Rule                                                                                                            |
| --------------- | --------------------------------------------------------------------------------------------------------------- |
| Type            | Object; string keys to string values.                                                                           |
| Keys            | Letters, digits and underscores, up to 128 characters. The reserved `telnyx_` prefix is rejected.               |
| Values          | Ciphertext per the [encryption scheme](#encryption-scheme); base64url; decoded size between 29 and 8,220 bytes. |
| Maximum entries | 32.                                                                                                             |
| Invalid entries | Dropped. The rest of the response is processed normally.                                                        |

The two sections are separate namespaces. An encrypted variable is never substituted into instructions, greetings, messages, or tool descriptions — only into the credential positions in [step 3](#3-reference-the-credential). A plain dynamic variable is never usable as a credential. Using the same name in both sections is not an error, but it is almost always a mistake, and Telnyx flags it as one.

### Encryption scheme

AES-256-GCM, nonce-prefixed, base64url-encoded:

```
base64url( nonce(12 bytes) || AES-256-GCM ciphertext+tag )
```

* **Nonce**: 12 random bytes, unique per encryption, prefixed to the ciphertext.
* **Tag**: the standard 16-byte GCM tag, appended by the cipher.
* **Plaintext**: an opaque UTF-8 string, at most 8 KB. No associated data.

Because GCM is authenticated, a wrong key or a modified ciphertext is detected and treated as a failed credential rather than partial plaintext.

<CodeGroup>
  ```python Python theme={null}
  import os, base64
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM

  key = base64.urlsafe_b64decode(ENCRYPTION_KEY)
  nonce = os.urandom(12)
  ciphertext = AESGCM(key).encrypt(nonce, b"user-bearer-token", None)
  value = base64.urlsafe_b64encode(nonce + ciphertext).decode()
  ```

  ```javascript Node theme={null}
  const crypto = require("crypto");

  const key = Buffer.from(ENCRYPTION_KEY, "base64url");
  const nonce = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv("aes-256-gcm", key, nonce);
  const ciphertext = Buffer.concat([
    cipher.update("user-bearer-token", "utf8"),
    cipher.final(),
    cipher.getAuthTag(),
  ]);
  const value = Buffer.concat([nonce, ciphertext]).toString("base64url");
  ```
</CodeGroup>

<Note>
  There is no `openssl enc` equivalent: that command does not support GCM.
</Note>

### Key rotation

Update the integration secret's value. Ciphertexts produced with the old key fail authentication — and the affected requests fail safely, per [failure behavior](#failure-behavior) — until the webhook encrypts with the new one. Rotate the key and the webhook together.

***

## 3. Reference the credential

Reference an encrypted variable with pipe syntax, naming the variable and the secret holding its decryption key. Whitespace around the parts and the pipe is optional.

```
{{variable_name | encryption_secret_ref}}
```

It is accepted in exactly two places.

### MCP server credential

The whole `api_key_ref` value is one reference:

```json theme={null}
{
  "name": "propertyradar",
  "type": "http",
  "url": "https://mcp.example.com",
  "api_key_ref": "{{mcp_token | mcp_enc_key}}"
}
```

Each conversation's decrypted `mcp_token` becomes that conversation's bearer token for the server. A plain identifier in `api_key_ref` keeps its existing meaning — one static integration secret for every conversation. The two forms are mutually exclusive per server.

### Tool webhook header values

As a token inside a configured header value, alongside the existing `{{#integration_secret}}` and `{{plain_variable}}` syntax:

```json theme={null}
{
  "headers": [
    { "name": "Authorization", "value": "Bearer {{user_token | tool_enc_key}}" }
  ]
}
```

### Everywhere else

Anywhere else — instructions, greetings, messages, tool descriptions, webhook URLs, preset body fields, preset query parameters — the reference is not resolved. Where Telnyx can tell at save time that a reference sits in a position that cannot resolve one, the configuration is rejected with a `422`.

Reading a configuration back always returns the reference verbatim. A decrypted value is never echoed on a configuration endpoint.

***

## When credentials refresh

| Channel | Resolved at                                                    | Refresh                                                     |
| ------- | -------------------------------------------------------------- | ----------------------------------------------------------- |
| Voice   | Call setup, from the webhook call that starts the conversation | Fixed for the call; refreshed on assistant handoff          |
| SMS     | Each turn, from the conversation's stored ciphertexts          | Refreshed by each platform-processed SMS webhook invocation |
| Chat    | Each turn, from the conversation's stored ciphertexts          | Reuses the most recently stored set                         |

Each webhook response **replaces** the stored set for that conversation in full, so a token refreshed on one turn is the token used on the next. A response that omits the section leaves the previous set in place; a response with an empty section clears it.

***

## Validation

Rejected at save time with a `422`:

* A value that looks like a reference but does not parse. A typo is never quietly downgraded to a plain variable, because that would send the literal `{{…}}` as a credential.
* An `encryption_secret_ref` that names no integration secret on the account.
* A reference in a position that cannot resolve one.

## Failure behavior

A credential that cannot be resolved at conversation time — the variable is missing from that conversation's set, the key secret is unavailable, or decryption fails — is never worked around:

| Position            | Behavior                                                                                                                                                                 |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| MCP server          | The server is **excluded from the conversation**. No connection is attempted, so none is ever made unauthenticated, and its tools are unavailable for that conversation. |
| Tool webhook header | The tool call **fails**. The request is not sent without the header that authenticates it.                                                                               |

There is no fallback to another credential, and the literal `{{…}}` is never sent. The failure is recorded with the variable name, the secret reference, and what went wrong — never with key material, ciphertext, or any part of the plaintext.

<Warning>
  An assistant whose MCP server drops out of a conversation loses that server's tools for the whole conversation. If a credential is optional for some callers, configure a second assistant or a [workflow](/docs/inference/ai-assistants/workflows) branch rather than relying on partial resolution.
</Warning>

***

## Confidentiality

* Values are stored and logged only as ciphertext.
* A decrypted value exists in memory only, at the moment it authenticates a request. It is never written to a log, a conversation record, or a webhook log.
* Encrypted variables cannot reach model-visible text: the templating that renders instructions, greetings, and tool descriptions does not resolve the pipe form at all.
* Decrypted values are never returned by a configuration endpoint.

***

## End-to-end example

1. Store `base64url(32 random bytes)` as integration secret `mcp_enc_key`.
2. Configure the MCP server with `"api_key_ref": "{{mcp_token | mcp_enc_key}}"`.
3. Point the assistant's `dynamic_variables_webhook_url` at the endpoint.
4. On each webhook call, identify the caller from the payload, fetch that user's token, encrypt it, and return it:

   ```json theme={null}
   {
     "encrypted_dynamic_variables": {
       "mcp_token": "<ciphertext>"
     }
   }
   ```

Every MCP request in that conversation now carries `Authorization: Bearer <that user's token>`. Two concurrent conversations for two different callers use two different tokens, and neither can reach the other's data.

***

## Related resources

* [Dynamic Variables](/docs/inference/ai-assistants/dynamic-variables) - The webhook this section extends, and the plain-variable syntax.
* [Integrations](/docs/inference/ai-assistants/integrations) - Store the encryption key as an integration secret.
* [Preset Webhook Parameters](/docs/inference/ai-assistants/preset-webhook-parameters) - Fixed tool values the model never sees.
* [Voice AI Assistant API Reference](/api-reference/assistants/create-an-assistant) - Assistant, MCP server, and tool configuration.
