> For the complete documentation index, see [llms.txt](https://docs.zama.org/protocol/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.zama.org/protocol/sdk/concepts/security-model.md).

# Security model

Threat model, trust assumptions, and security architecture of the Zama SDK.

This page describes what the SDK protects, what it exposes, and the trust assumptions underlying its design. Understanding these boundaries helps you make informed decisions about deploying confidential tokens.

## What is encrypted

Confidential tokens encrypt **balances** and **confidential transfer amounts**. When a user transfers 500 tokens privately, the plaintext amount is FHE-encrypted client-side before the transaction reaches the blockchain, and the on-chain contract only ever sees the ciphertext.

Shielding and unshielding are the public boundary: they convert tokens between a public ERC-20 and its confidential form, so the **shield/unshield amount is visible on-chain** — it is an ordinary public ERC-20 movement. Privacy begins once tokens are in confidential form: the resulting balance is encrypted, and later confidential transfers hide their amounts.

The on-chain contract stores FHE ciphertexts instead of `uint256` values. Only the balance owner (via their FHE private key and the relayer KMS) can decrypt their own balance.

## What is visible

FHE protects values, not metadata. The following remain publicly observable on-chain:

* **Transaction existence** — that a transaction occurred is visible in the block.
* **Participant addresses** — sender and receiver addresses are part of the transaction.
* **Token contract address** — which confidential token is involved.
* **Transaction type** — whether the call is a shield, transfer, unshield, or approval.
* **Shield and unshield amounts** — converting between public ERC-20 and confidential form is a public ERC-20 transfer, so the converted amount is visible. Only confidential transfers hide their amounts.
* **Gas costs** — standard Ethereum gas accounting.
* **Timing** — when transactions occur.

An observer can see that address A sent a confidential transfer to address B on token contract C. They cannot see how much was sent.

{% hint style="info" %}
This is a value-privacy model, not a full-privacy model. It protects amounts while preserving the public verifiability that makes Ethereum useful. For transaction-graph privacy, additional measures (like mixing services or stealth addresses) would be needed on top of FHE.
{% endhint %}

## Trust assumptions

### The relayer and KMS

The relayer provides the FHE infrastructure: encryption, decryption coordination, and transport key pair generation. The Key Management Service (KMS) holds the network's FHE master key and performs re-encryption.

The critical trust property: **the KMS re-encrypts ciphertexts without learning plaintext values.** When a user requests their balance, the KMS transforms the on-chain ciphertext from the network key to the user's public key. The KMS sees ciphertexts in and ciphertexts out — never plaintext.

This is a cryptographic property of the re-encryption scheme, not a policy promise. The KMS cannot extract plaintext from the ciphertexts it processes, assuming the underlying TFHE scheme is secure.

{% hint style="warning" %}
The KMS must be available for decryption to work. If the relayer is down, users cannot read their balances or finalize unshield operations. The on-chain encrypted data remains safe — it is inaccessible without the FHE infrastructure, but also unreadable until the relayer returns.
{% endhint %}

### The blockchain

The on-chain FHE coprocessor (FHEVM) executes homomorphic operations. It must correctly perform encrypted arithmetic for transfers and balance updates. This is part of the blockchain's consensus — nodes verify FHE operations as part of block validation.

### The user's wallet

The wallet signs EIP-712 typed data to authorize FHE operations. The SDK trusts that the wallet correctly implements `eth_signTypedData_v4` and that the signing key is under the user's control. A compromised wallet compromises the FHE session — the attacker could sign authorization requests and decrypt the user's balances.

## Credential storage

### Transport key pair storage

By default, the transport private key is stored in plaintext in the configured storage backend (typically IndexedDB in browsers), with no encryption-at-rest layer. This is correct for most contexts: browser dApps have IndexedDB behind OS-level disk encryption, mobile apps have platform keychains, server operators typically have a KMS/Vault/HSM in front of their storage. See [Wrapped at rest](#wrapped-at-rest-transportkeypairderivationsecret) below for the opt-in alternative when none of that exists.

![Transport key pair storage](https://791615601-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F7slMbEM0iSt4PQVQHnaS%2Fuploads%2Fgit-blob-3f86a16be44cc25f6e892107d79c15dc5b30b565%2Fcredential-storage.svg?alt=media)

| Parameter  | Value                                                                             |
| ---------- | --------------------------------------------------------------------------------- |
| Storage    | IndexedDB (browser), in-memory otherwise (pass a persistent `storage` on servers) |
| Key format | Plaintext ML-KEM key pair by default, see below for wrapped                       |
| Scope      | One transport key pair per signer address by default (chain-independent)          |

The security model relies on same-origin isolation: only JavaScript running on the same origin can read IndexedDB. See [Permit Model](/protocol/sdk/concepts/permit-model.md) for the full lifecycle.

### Wrapped at rest (`transportKeyPairDerivationSecret`)

"Wrapping" here means encrypting the transport private key before it is written to storage. It is unrelated to wrapping ERC-20 tokens into confidential tokens (`shield`/`unshield`).

**The threat.** An attacker obtains a copy of your storage backend: a stolen disk, a leaked database dump, an exposed backup. The attacker does not control your running process. By default the transport private key sits in that copy as plaintext, so the attacker reads it directly. With `transportKeyPairDerivationSecret`, the attacker gets ciphertext instead. This holds only if the secret lives outside the stolen storage.

**How it works.** Pass the secret at construction: `new ZamaSDK(config, { transportKeyPairDerivationSecret })`. It is a constructor option, not part of the shareable config object, so the secret can never travel with the config. The SDK encrypts the private key before every write and decrypts it after every read. No other API changes. The SDK never persists the secret and never exposes it on any object. The constructor rejects the option in browser contexts, and rejects an `undefined` value (the unset-env-var case); see [Configuration](/protocol/sdk/guides/configuration.md#10-optional-wrap-the-transport-key-pair-at-rest-headless-environments) for the guards and setup.

```
wrappingKey = HKDF-SHA256(ikm: transportKeyPairDerivationSecret, salt: "scope:<transportKeyPairScope>" or "signer:<address>", info: "zama-sdk-keypair-wrapping-v1")
ciphertext  = AES-256-GCM(wrappingKey, random 96-bit IV, privateKey, aad: publicKey + createdAt + expiresAt + tkmsVersion)
```

The salt is the storage identity: `transportKeyPairScope` if set, else the signer address, prefixed by kind so the two namespaces never collide. Signers in one scope share one wrapping key. Only the private key is encrypted. The public key, timestamps, and TKMS version stay readable, but are bound to the ciphertext as AES-GCM additional authenticated data: tampering with any of them fails decryption. Permits stay in plaintext because they are already public data. Each wrapped entry records `wrappingVersion: 1`, so a future scheme's entries are recognizable rather than treated as corruption.

Prefer a `Uint8Array` secret. The SDK copies it, imports the copy into a non-extractable WebCrypto key on first use, and zeroizes that copy. The SDK never touches your original buffer: zeroize it after construction. A `string` cannot be scrubbed: JavaScript strings are immutable, so copies may remain in the heap.

{% hint style="warning" %}
**The secret must be random.** The SDK enforces a minimum length (32 bytes for a `Uint8Array`, 64 characters for a string), but length is not entropy. Generate it with a CSPRNG or issue it from a secrets manager: `crypto.randomBytes(32)` in Node.js, or `openssl rand -hex 32` for an env var. Never use a passphrase: HKDF does no key-stretching, so a human-memorable secret can be brute-forced offline.
{% endhint %}

| Storage mode                            | Option                             | Storage needed? | Secure storage needed?    |
| --------------------------------------- | ---------------------------------- | --------------- | ------------------------- |
| Plaintext, delegated security (default) | n/a                                | Yes             | Yes (your responsibility) |
| Wrapped at rest                         | `transportKeyPairDerivationSecret` | Yes             | No, any key-value store   |

{% hint style="info" %}
This doesn't eliminate storage: the (now wrapped) key pair is still persisted. It eliminates the requirement for storage to be *secure*.
{% endhint %}

#### What not to do

The protection fails if a storage-level attacker can also reach the secret. Source it out of band: a secrets manager, a KMS-unwrapped blob, an env var injected at deploy time.

{% hint style="danger" %}

* **Do not store the secret next to the key pair it wraps.** That puts the lock and key in the same drawer: an attacker who reads one reads both.
* **Do not ship it in a build artifact.** Bundlers inline `process.env` at build time, so the secret travels with every copy of the artifact.
* **Do not share one secret across a fleet without per-instance or per-tenant separation.** One captured instance then unlocks every other instance's storage.
* **Do not wrap storage that is already secure.** On a platform keychain, a KMS-backed store, or an at-rest-encrypted backend, wrapping adds rotation and distribution burden without adding security.
  {% endhint %}

#### Rotation

The secret is set once, at construction. To rotate it, restart with the new value. Without a scope, the old wrapped entry fails to decrypt, the SDK treats that as a cache miss and deletes it, and the next `grantPermit()` generates a fresh key pair wrapped with the new secret. With a scope, rotation does not self-heal: follow the migration steps in the warning below. Replacing a lost secret with a new one has the same cost as rotating it.

A fresh key pair invalidates every permit granted for the old one, so the signer must re-sign. The number of wallet prompts depends on how your app grants permits: one prompt if you re-grant the full contract set in one call, several spread over time if you grant lazily per contract. To avoid extra prompts, rotate the secret when the key pair expires anyway; `transportKeyPairTTL` sets that cadence.

{% hint style="warning" %}
One case does not self-heal: an instance with no `transportKeyPairDerivationSecret` configured that reads a wrapped entry throws [`KeyWrappingError`](/protocol/sdk/api-references/sdk/errors.md#keywrappingerror) instead of silently downgrading to plaintext. The entry is preserved: restore the secret to restore access, or call `sdk.permits.clear()` to downgrade deliberately.

With a shared scope, no mismatch self-heals: the wrong secret, a missing secret, and a plaintext entry read by a wrapped instance all throw instead of overwriting the entry other signers depend on. Configure the same secret on every instance in the scope before the first wrapped write. To migrate a live scope, configure the secret everywhere, then call `sdk.permits.revokeTransportKeyPair(scopeId)` once; the next access regenerates the entry wrapped.
{% endhint %}

### Shared-tenant scope (B2B2C / WaaS operators)

`transportKeyPairScope` is an opt-in escape hatch from the per-signer default. When configured, every signer that shares the same scope identifier reads and writes the *same* transport key pair instead of generating one per signer address.

**When it's the right tradeoff.** A separately-tenanted deployment (browser or mobile dApp, treasury wallet) gets real defense-in-depth from per-signer keys: compromising one signer's storage never exposes another user's key. A shared-tenant Wallet-as-a-Service operator holding thousands of client wallets behind one operator-controlled key store gets none of that benefit — if the store is breached, every wallet behind it is compromised together regardless of how many key pairs exist. In that case, per-signer keys are pure overhead (generation, storage rows, management) with no corresponding security gain, and `transportKeyPairScope` lets the operator collapse them into one.

**When it isn't.** If signers are genuinely isolated — separate end-user devices, separate trust boundaries, separate storage backends — per-signer keys (the default) give you isolation that a shared scope would throw away. Don't configure a scope just to save storage rows if your signers don't already share one key store.

**What stays isolated regardless of scope.** Permits are always per-signer: the EIP-712 signature is inherently tied to the signing wallet, so two signers in the same scope never see each other's permits, only the underlying key pair. See [Permit Model](/protocol/sdk/concepts/permit-model.md#revocation) for how revocation is split into two tiers to preserve this isolation.

{% hint style="warning" %}
Sharing only works if every signer in the scope reads and writes the **same** storage instance. `asyncLocalStorage` — the storage recommended for [Node.js servers](/protocol/sdk/guides/configuration.md#6-optional-choose-a-storage-backend) — isolates a fresh, empty store per request by design, which defeats sharing entirely: each request would regenerate the "shared" key pair and lose it immediately after. WaaS operators need one persistent `GenericStorage` (e.g. a database- or Redis-backed adapter) wired into every `ZamaSDK` instance that shares a scope.
{% endhint %}

**Onboarding race.** `GenericStorage` has no compare-and-swap, so the very first time a scope's key pair is created, concurrent signers can race the underlying storage. This isn't just a more-likely version of the ordinary per-signer race (two tabs for the same end-user, where the loser just re-signs) — under a shared scope the racers are typically *different* end-users behind the same operator, so the losing side can have an already-granted permit silently pruned as stale through no action of its own, and because one slot serves the whole cohort, every TTL expiry or `revokeTransportKeyPair()` call is a correlated event that can push many signers into the window at once. Pre-warm a scope's key pair once with `sdk.permits.warmTransportKeyPairScope(scopeId)` before opening concurrent traffic to it, rather than letting the first wave of real users race each other.

{% hint style="info" %}
Use `sdk.permits.warmTransportKeyPairScope(scopeId)`, not `sdk.permits.warmTransportKeyPair()`, to pre-warm a scope. The latter is gated on a connected wallet-account snapshot — a precondition inherited from its per-signer use case that doesn't apply to a scope-wide key, and it would silently no-op precisely when an operator is most likely to call it: before any end-user is connected. `warmTransportKeyPairScope()` needs no wallet account *connected* — though, like the rest of the `credentials/` facade, it still requires a `signer` to be configured on the `ZamaSDK` instance at construction time — matching `revokeTransportKeyPair()`'s operator-level design.
{% endhint %}

### Limitations

<details>

<summary>What same-origin isolation does NOT protect against</summary>

* **Same-origin scripts** — any JavaScript running on the same origin can read IndexedDB. A cross-site scripting (XSS) vulnerability could access the transport private key directly. Reducing XSS surface is essential.
* **Physical device access** — someone with access to the device's file system can read the IndexedDB contents.
* **Malicious browser extensions** — extensions with broad permissions can access IndexedDB. Users should audit their installed extensions.

</details>

## WASM bundle integrity

The TFHE WASM binaries ship **inside the `@fhevm/sdk` npm package** and load from your own bundle — there is no runtime CDN fetch. Integrity is guaranteed the same way as any other dependency: by your package manager's lockfile hashes and your build pipeline.

For advanced deployments that host the WASM assets on a URL instead (via the `runtime` option's `wasmAssetLoadMode` / `locateFile`), the integrity guarantee depends on the mode: `verified-blob` SHA-verifies the fetched bytes against hashes pinned in the library and fails initialization on mismatch, `precheck-direct-url` runs a precheck request before loading directly from the URL, and `trusted-direct-url` loads directly with no verification (fastest, least defensive). The `embedded-base64` mode inlines the WASM as base64 with no network fetch at all. See the [`wasmAssetLoadMode` mode table](/protocol/sdk/changelog/beta.md) for the full list.

![WASM loading and integrity](https://791615601-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F7slMbEM0iSt4PQVQHnaS%2Fuploads%2Fgit-blob-0fc306385c78779bc45cbb2cd14902b8e20b6a12%2Fsecurity-wasm-integrity.svg?alt=media)

## Browser security headers

### COOP/COEP headers

Multi-threaded FHE uses `SharedArrayBuffer`, which browsers restrict to cross-origin isolated contexts. To enable multi-threaded encryption, your server must send these headers:

```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```

Without them, `SharedArrayBuffer` is unavailable and the SDK falls back to single-threaded WASM execution — slower, but fully functional. When this happens, `@fhevm/sdk` logs a console warning naming the two headers.

{% hint style="info" %}
These headers are a performance setting, not a hard requirement. Encryption works without them in single-threaded mode. Only enable cross-origin isolation if you want the throughput of multi-threaded FHE.
{% endhint %}

### Content Security Policy (CSP)

The SDK compiles and executes the bundled WASM in the page, and — in multi-threaded mode — spawns its worker pool from embedded source. Your CSP must allow:

| Directive     | Value                | Reason                                                     |
| ------------- | -------------------- | ---------------------------------------------------------- |
| `script-src`  | `'wasm-unsafe-eval'` | Required for WASM compilation and execution                |
| `worker-src`  | `blob:`              | Multi-threaded mode creates its worker pool from blob URLs |
| `connect-src` | your relayer URL     | Encrypt/decrypt requests go to the relayer (or your proxy) |

Example CSP header:

```
Content-Security-Policy: worker-src blob:; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' https://relayer.testnet.zama.org https://your-relayer-proxy.com;
```

<details>

<summary>Why wasm-unsafe-eval?</summary>

The `wasm-unsafe-eval` directive allows WASM compilation and execution without requiring `unsafe-eval`. It is narrower than `unsafe-eval` — it permits only WebAssembly instantiation, not arbitrary JavaScript `eval()`. All major browsers support it as of 2024.

</details>

## Permit security

### Time-bounded signatures

EIP-712 permit signatures include a start timestamp and duration (in days). The relayer rejects permits outside their validity window. This limits the damage from a leaked permit — it becomes useless after expiry.

Two TTL controls are available:

* `transportKeyPairTTL` — how long the transport key pair remains valid (default: 30 days).
* `permitTTL` — how long signed permits remain valid, in days (default: 30).

### Address-scoped authorization

The EIP-712 typed data includes the wallet address. A permit signed by address A cannot authorize decryption for address B. Combined with contract-scoped authorization (the signed message lists specific contract addresses), each permit is tightly bound to a specific user and set of contracts.

### Revocation

Permits can be revoked programmatically via `sdk.permits.revokePermits()` or automatically via wallet lifecycle events (disconnect, account switch). Revocation removes permits from storage immediately.

After revoking permits, the transport key pair remains in storage. Use `sdk.permits.clear()` to also wipe the key pair.

**With a shared `transportKeyPairScope` configured, these signer-level operations never touch the shared key pair** — `sdk.permits.clear()` for one signer only ever removes that signer's own permits, exactly like `revokePermits()`. This is deliberate: one end-user disconnecting must never invalidate every other signer sharing the scope's operator-controlled key store.

Invalidating the shared key pair itself is a distinct, operator-level operation: `sdk.permits.revokeTransportKeyPair(scopeId)`. It deletes the scope's key pair; every permit in the scope embeds that key pair's public key, so they're all treated as stale on next access — no permit needs to be touched directly, and no wallet needs to be connected. Use it for suspected compromise, periodic rotation, or scope decommissioning — never as a side effect of a single signer's revoke.

`revokeTransportKeyPair()` only stops the SDK from reissuing or reusing the deleted key going forward — it does not revoke permits already issued under it. A permit is a self-contained, bearer-style EIP-712 signature the relayer accepts on its own terms; nothing in this SDK (or, from the client's perspective, on the relayer/KMS side) can push-revoke one server-side. If a compromise already exfiltrated both the shared private key and a still-valid permit — realistic, since permits and the key pair share the same `storage` by default unless `permitStorage` is configured separately — that permit remains usable against the relayer directly, bypassing this SDK entirely, until its own `permitTTL` expiry. `revokeTransportKeyPair()` closes the SDK-local half of the exposure; it is not a substitute for treating any already-issued permit from a compromised store as live until it naturally expires.

## CSRF protection

For browser apps, the `web()` transport supports CSRF tokens injected into all mutating HTTP requests to the relayer proxy:

```ts
const config = createConfig({
  chains: [sepolia],
  publicClient,
  walletClient,
  relayers: {
    [sepolia.id]: web({
      security: { getCsrfToken: () => document.cookie.match(/csrf=(\w+)/)?.[1] ?? "" },
    }),
  },
});
```

The token is refreshed before each encrypt/decrypt call. Only POST, PUT, DELETE, and PATCH requests to the relayer URL include the CSRF header. GET requests and non-relayer URLs pass through without modification.

## Summary of cryptographic algorithms

| Operation                       | Algorithm                     | Key size    | Source                |
| ------------------------------- | ----------------------------- | ----------- | --------------------- |
| WASM integrity (URL mode, opt.) | SHA-384                       | --          | Web Crypto API        |
| Transport key wrapping (opt.)   | HKDF-SHA256, then AES-256-GCM | 256-bit     | Web Crypto API        |
| FHE encryption                  | TFHE                          | Network key | WASM (`@fhevm/sdk`)   |
| ZK proofs                       | WASM prover                   | --          | WASM (`@fhevm/sdk`)   |
| Wallet signing                  | ECDSA secp256k1               | 256-bit     | User wallet           |
| Request tracking                | UUID v4                       | 128-bit     | `crypto.randomUUID()` |

## Reporting vulnerabilities

If you discover a security vulnerability in the SDK, report it to **<security@zama.ai>**. Do not open a public GitHub issue for security reports. See the [Security Policy](https://github.com/zama-ai/sdk/blob/main/SECURITY.md) for full details.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.zama.org/protocol/sdk/concepts/security-model.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
