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

# Encryption Key Rotation

> Re-encrypt tenant ciphertext stores under a new key version without downtime. Admin-only; MFA step-up applies to session admins.

The `POST /api/v1/encryption/rotate` route re-encrypts all ciphertext held for the calling tenant under the current key version. It covers every store that uses the platform's AES-256-GCM versioned encryption, and runs as a dry-run or a live pass depending on the `dryRun` flag.

## POST /api/v1/encryption/rotate

**Auth:** admin role + tenant scope. MFA posture depends on the caller:

* **Session admins** — the middleware step-up gate requires a valid, fresh `kaireon_stepup` cookie (the same 15-minute HMAC proof that protects all admin writes) before this write is allowed. See [MFA enforcement](/governance-security/mfa-enforcement).
* **`krn_` API-key callers** — these carry no session JWT, so the middleware MFA gate is skipped. This is the platform-wide posture for **all** admin writes via API key, not specific to this route — key possession alone gates them.

### Request body

| Field    | Type    | Default | Description                                                                                                                                                   |
| -------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dryRun` | boolean | `false` | When `true`, performs a decrypt-only pass and counts what would rotate — no writes are made. Dry runs are audit-logged so they appear in compliance evidence. |

### Covered stores

| Store             | DB column                    | Encryption used                             |
| ----------------- | ---------------------------- | ------------------------------------------- |
| Connectors        | `Connector.authConfig`       | Versioned AES-256-GCM (`encryption.ts`)     |
| Platform settings | `PlatformSetting.value`      | Binary AES-256-GCM (`platform-settings.ts`) |
| SSO OIDC secrets  | `SsoConfig.oidcClientSecret` | Versioned AES-256-GCM (`encryption.ts`)     |
| MFA TOTP secrets  | `User.mfaSecret`             | Versioned AES-256-GCM (`encryption.ts`)     |

### Not covered (`notRotated`)

The response always includes a `notRotated[]` array for stores that cannot be rotated:

| Store                                 | Reason                                                                                                                                                                                    |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `User.mfaBackupCodes`                 | SHA-256 hashes — not encrypted; the original plaintext codes are not stored so they cannot be re-encrypted                                                                                |
| `DSAR exports (dsar_exports.payload)` | Stored as plaintext portable JSON — there is no ciphertext to rotate. Encrypted-mode exports should be re-run with the current key. Payloads are purged on the decisions retention clock. |

### Response

```json theme={null}
{
  "dryRun": false,
  "keyVersion": "2",
  "connectors": { "total": 12, "rotated": 12, "errors": [] },
  "platformSettings": { "total": 4, "rotated": 4, "errors": [] },
  "sso": { "total": 1, "rotated": 1, "errors": [] },
  "mfa": { "total": 38, "rotated": 38, "errors": [] },
  "notRotated": [
    {
      "store": "User.mfaBackupCodes",
      "reason": "SHA-256 hashes — not encrypted; cannot be re-encrypted without the original plaintext codes"
    },
    {
      "store": "DSAR exports (dsar_exports.payload)",
      "reason": "stored as plaintext portable JSON (no ciphertext to rotate); encrypted-mode exports should be re-run with the current key; purged on the decisions retention clock"
    }
  ]
}
```

Each store returns `{ total, rotated, errors[] }`. `total` is the number of rows fetched; `rotated` is the count successfully re-encrypted (or that would be, in dry-run); `errors[]` holds per-row error strings.

<Warning>
  **A secret that cannot be decrypted is never overwritten.** If the ciphertext fails to authenticate — tampering, or the key that encrypted it is no longer configured — rotation refuses that row, leaves the stored value untouched, and records it in `errors[]`. This matters: an undecryptable ciphertext is still recoverable if the original key is restored, whereas one that had been re-encrypted from an empty payload would be gone permanently. Such rows are counted in `total` but not in `rotated`, so `rotated < total` with entries in `errors[]` is the signal to investigate before assuming rotation is complete.
</Warning>

### Row cap

Each store is capped at **5,000 rows per pass**. When a store exceeds the cap, the pass is truncated and the store's `errors[]` array contains a message indicating the cap was hit. Re-running the route does **not** auto-advance past the already-rotated rows — the cap applies to the first 5,000 rows fetched by the WHERE clause on every run. Tenants with more than 5,000 rows in a store need staged rotation (e.g. run the route repeatedly until all `errors` disappear).

### Keyless development

When `CONNECTOR_ENCRYPTION_KEY` is not set (local development), the platform-settings rotation performs a clean no-op. All other stores behave normally because they derive their key from the same env var.

### Environment variables

| Variable                                    | Purpose                                                                  |
| ------------------------------------------- | ------------------------------------------------------------------------ |
| `CONNECTOR_ENCRYPTION_KEY`                  | Current encryption key (required in production; omit for dev no-op mode) |
| `CONNECTOR_ENCRYPTION_KEY_VERSION`          | Version tag for the current key (default: `"1"`)                         |
| `CONNECTOR_ENCRYPTION_KEY_PREVIOUS`         | Previous key for decrypting old ciphertext during rotation               |
| `CONNECTOR_ENCRYPTION_KEY_PREVIOUS_VERSION` | Version tag for the previous key (default: `"0"`)                        |

### Rotation workflow

1. Generate a new key and set it as `CONNECTOR_ENCRYPTION_KEY` with `CONNECTOR_ENCRYPTION_KEY_VERSION=2` (or your next version string).
2. Set the old key as `CONNECTOR_ENCRYPTION_KEY_PREVIOUS` with `CONNECTOR_ENCRYPTION_KEY_PREVIOUS_VERSION=1`.
3. Redeploy so the new env vars are live.
4. Call `POST /api/v1/encryption/rotate` with `{ "dryRun": true }` to verify what will be rotated.
5. Call `POST /api/v1/encryption/rotate` (live pass). Check all stores report `errors: []`.
6. For tenants with > 5,000 rows in any store, repeat step 5 until errors disappear.
7. Once rotation is confirmed complete, you can safely clear `CONNECTOR_ENCRYPTION_KEY_PREVIOUS`.

### Status codes

| Code          | Cause                                                                                   |
| ------------- | --------------------------------------------------------------------------------------- |
| `200`         | Rotation (or dry-run) completed; inspect per-store `errors[]` for partial failures      |
| `400`         | Invalid request body                                                                    |
| `401` / `403` | Missing tenant context, wrong role, or (session admins only) missing MFA step-up cookie |
| `500`         | Unexpected server error                                                                 |

## Related

* [MFA enforcement](/governance-security/mfa-enforcement) — the step-up gate protecting session-admin writes to this route
* [DSAR](/api-reference/dsar) — export payloads that age out independently of this rotation
* [Security hardening](/self-host/operate/security-hardening) — operator checklist
