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

# API Key Rotation

Programmatic key rotation lets you replace an API key via the [Rotate API Key](/api-reference/prime-api/rest-api/api-key-management/rotate-api-key) endpoint. The new key is provisioned programmatically, but an authorized approver must still approve the rotation in the Prime UI before it activates.

## How rotation works

1. **Request rotation** — Call `POST /v1/api-keys/rotate`. The response returns an `encrypted_credentials` payload and an `activity_id`.
2. **Decrypt the response** — Decrypt `encrypted_credentials` using your `secret_key` to retrieve the new key credentials. See [Decrypting the response](#decrypting-the-response).
3. **Approve in Prime UI** — One or more authorized approvers must approve the rotation activity in the Prime UI, depending on your consensus policy. Once all required approvals are met, the new key activates.
4. **Transition period** — For `duration_seconds` after approval, both keys are active. Set `duration_seconds: 0` to expire the old key immediately on approval.
5. **Swap credentials** — Update your integration with the new credentials before the transition period ends.

## Operational guidance

**When to rotate**

* **Expiring keys** — Rotate at least 24 hours before expiry. If the key expires before the rotation activity is approved, the rotation fails and you will need to create a new key manually through the Prime UI.
* **Non-expiring keys** — Rotate periodically as part of your security hygiene.

**Choosing `duration_seconds`**

* Set to `0` for an immediate cutover — the old key expires the moment the rotation is approved. Use this when all services can be updated atomically.
* Set a grace period long enough to redeploy every service that uses the key. Both keys remain active during this window, so you can roll out new credentials without downtime. The maximum is 30 days.

## Required scope

The invoking key must have the **Rotate API Key** scope enabled. This scope is found under the **API Key Management** section when creating a key in the Prime UI and cannot be changed after creation.

## Decrypting the response

`encrypted_credentials` is encrypted with **HKDF-SHA256 + AES-256-GCM** using your `secret_key`.

### Wire format

After base64-decoding:

```
version(1 byte) | salt(32 bytes) | nonce(12 bytes) | ciphertext+tag(plaintext_len + 16 bytes)
```

* **version** — Always `0x01`. Reject any other value.
* **salt** — Random 32-byte HKDF salt.
* **nonce** — Random 12-byte AES-GCM nonce.
* **ciphertext+tag** — AES-256-GCM encrypted payload with the 16-byte authentication tag appended. Pass the entire value to your GCM decrypt function.

HKDF-SHA256 key derivation parameters:

| Parameter | Value                                                       |
| :-------- | :---------------------------------------------------------- |
| `ikm`     | `secret_key.encode()` — raw bytes of your secret key string |
| `salt`    | 32-byte salt from the wire format                           |
| `info`    | `api-key-rotation`                                          |
| `length`  | `32`                                                        |

After decryption, the plaintext is a JSON object:

```json theme={null}
{
  "access_key": "...",
  "secret_key": "...",
  "passphrase": "...",
  "service_account_id": "..."
}
```

### Decryption example

<Tabs groupId="programming-language">
  <Tab value="Python" title="Python">
    ```python wrap theme={null}
    import base64
    import json

    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    from cryptography.hazmat.primitives.hashes import SHA256
    from cryptography.hazmat.primitives.kdf.hkdf import HKDF

    HKDF_INFO          = b"api-key-rotation"
    VERSION_LEN        = 1
    SALT_LEN           = 32
    NONCE_LEN          = 12
    SUPPORTED_VERSIONS = {1}


    def parse_wire(encrypted_b64: str) -> tuple[bytes, bytes, bytes]:
        raw     = base64.b64decode(encrypted_b64 + "==")  # pad-tolerant
        version = raw[0]
        if version not in SUPPORTED_VERSIONS:
            raise ValueError(f"unsupported wire version: {version}")
        salt       = raw[VERSION_LEN:VERSION_LEN + SALT_LEN]
        nonce      = raw[VERSION_LEN + SALT_LEN:VERSION_LEN + SALT_LEN + NONCE_LEN]
        ciphertext = raw[VERSION_LEN + SALT_LEN + NONCE_LEN:]
        return salt, nonce, ciphertext


    def decrypt(secret_key: str, encrypted_b64: str) -> dict:
        salt, nonce, ciphertext = parse_wire(encrypted_b64)
        ikm     = secret_key.encode()
        aes_key = HKDF(algorithm=SHA256(), length=32, salt=salt, info=HKDF_INFO).derive(ikm)
        plaintext = AESGCM(aes_key).decrypt(nonce, ciphertext, None)
        return json.loads(plaintext)


    creds = decrypt(
        secret_key=SECRET_KEY,
        encrypted_b64=response["encrypted_credentials"],
    )
    # Store creds["access_key"], creds["secret_key"], and creds["passphrase"]
    ```
  </Tab>

  <Tab value="Typescript" title="TS/JS">
    ```typescript wrap theme={null}
    import { hkdfSync, createDecipheriv } from 'node:crypto';

    const HKDF_INFO  = Buffer.from('api-key-rotation');
    const VERSION_LEN = 1, SALT_LEN = 32, NONCE_LEN = 12;

    function decrypt(secretKey: string, encryptedB64: string): Record<string, string> {
      const raw = Buffer.from(encryptedB64, 'base64');
      if (raw[0] !== 1) throw new Error(`unsupported wire version: ${raw[0]}`);

      const salt       = raw.subarray(VERSION_LEN, VERSION_LEN + SALT_LEN);
      const nonce      = raw.subarray(VERSION_LEN + SALT_LEN, VERSION_LEN + SALT_LEN + NONCE_LEN);
      const ciphertext = raw.subarray(VERSION_LEN + SALT_LEN + NONCE_LEN);

      const aesKey = Buffer.from(hkdfSync('sha256', Buffer.from(secretKey), salt, HKDF_INFO, 32));

      const tag      = ciphertext.subarray(ciphertext.length - 16);
      const ct       = ciphertext.subarray(0, ciphertext.length - 16);
      const decipher = createDecipheriv('aes-256-gcm', aesKey, nonce);
      decipher.setAuthTag(tag);
      const plaintext = Buffer.concat([decipher.update(ct), decipher.final()]);

      return JSON.parse(plaintext.toString());
    }

    const creds = decrypt(SECRET_KEY, response.encrypted_credentials);
    // Store creds.access_key, creds.secret_key, and creds.passphrase
    ```
  </Tab>

  <Tab value="Go" title="Go">
    ```go wrap theme={null}
    import (
        "crypto/aes"
        "crypto/cipher"
        "crypto/sha256"
        "encoding/base64"
        "encoding/json"
        "fmt"
        "io"

        "golang.org/x/crypto/hkdf"
    )

    const (
        hkdfInfo   = "api-key-rotation"
        versionLen = 1
        saltLen    = 32
        nonceLen   = 12
    )

    func decryptCredentials(secretKey, encryptedB64 string) (map[string]string, error) {
        raw, err := base64.StdEncoding.DecodeString(encryptedB64)
        if err != nil {
            raw, err = base64.RawStdEncoding.DecodeString(encryptedB64)
            if err != nil {
                return nil, fmt.Errorf("base64 decode: %w", err)
            }
        }
        if raw[0] != 1 {
            return nil, fmt.Errorf("unsupported wire version: %d", raw[0])
        }

        salt       := raw[versionLen : versionLen+saltLen]
        nonce      := raw[versionLen+saltLen : versionLen+saltLen+nonceLen]
        ciphertext := raw[versionLen+saltLen+nonceLen:]

        aesKey := make([]byte, 32)
        r := hkdf.New(sha256.New, []byte(secretKey), salt, []byte(hkdfInfo))
        if _, err := io.ReadFull(r, aesKey); err != nil {
            return nil, fmt.Errorf("hkdf: %w", err)
        }

        block, err := aes.NewCipher(aesKey)
        if err != nil {
            return nil, fmt.Errorf("aes: %w", err)
        }
        gcm, err := cipher.NewGCM(block)
        if err != nil {
            return nil, fmt.Errorf("gcm: %w", err)
        }
        plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
        if err != nil {
            return nil, fmt.Errorf("decrypt: %w", err)
        }

        var creds map[string]string
        if err := json.Unmarshal(plaintext, &creds); err != nil {
            return nil, fmt.Errorf("json: %w", err)
        }
        return creds, nil
    }

    // Usage
    creds, err := decryptCredentials(SECRET_KEY, response.EncryptedCredentials)
    // Store creds["access_key"], creds["secret_key"], and creds["passphrase"]
    ```
  </Tab>

  <Tab value="Java" title="Java">
    ```java wrap theme={null}
    import org.bouncycastle.crypto.generators.HKDFBytesGenerator;
    import org.bouncycastle.crypto.params.HKDFParameters;
    import org.bouncycastle.crypto.digests.SHA256Digest;
    import javax.crypto.Cipher;
    import javax.crypto.spec.GCMParameterSpec;
    import javax.crypto.spec.SecretKeySpec;
    import java.nio.charset.StandardCharsets;
    import java.util.Arrays;
    import java.util.Base64;
    import java.util.Map;
    import com.fasterxml.jackson.databind.ObjectMapper;

    private static final byte[] HKDF_INFO  = "api-key-rotation".getBytes(StandardCharsets.UTF_8);
    private static final int VERSION_LEN = 1, SALT_LEN = 32, NONCE_LEN = 12;

    public static Map<String, String> decryptCredentials(String secretKey, String encryptedB64) throws Exception {
        byte[] raw = Base64.getDecoder().decode(encryptedB64 + "==");
        if (raw[0] != 1) throw new IllegalArgumentException("unsupported wire version: " + raw[0]);

        byte[] salt       = Arrays.copyOfRange(raw, VERSION_LEN, VERSION_LEN + SALT_LEN);
        byte[] nonce      = Arrays.copyOfRange(raw, VERSION_LEN + SALT_LEN, VERSION_LEN + SALT_LEN + NONCE_LEN);
        byte[] ciphertext = Arrays.copyOfRange(raw, VERSION_LEN + SALT_LEN + NONCE_LEN, raw.length);

        byte[] ikm = secretKey.getBytes(StandardCharsets.UTF_8);
        HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest());
        hkdf.init(new HKDFParameters(ikm, salt, HKDF_INFO));
        byte[] aesKey = new byte[32];
        hkdf.generateBytes(aesKey, 0, 32);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new GCMParameterSpec(128, nonce));
        byte[] plaintext = cipher.doFinal(ciphertext);

        return new ObjectMapper().readValue(plaintext, Map.class);
    }

    // Usage
    Map<String, String> creds = decryptCredentials(SECRET_KEY, response.getEncryptedCredentials());
    // Store creds.get("access_key"), creds.get("secret_key"), creds.get("passphrase")
    ```
  </Tab>

  <Tab value=".NET" title=".NET">
    ```csharp wrap theme={null}
    using System;
    using System.Collections.Generic;
    using System.Security.Cryptography;
    using System.Text;
    using System.Text.Json;

    const string HkdfInfo  = "api-key-rotation";
    const int    VersionLen = 1, SaltLen = 32, NonceLen = 12;

    static Dictionary<string, string> DecryptCredentials(string secretKey, string encryptedB64)
    {
        string padded    = encryptedB64.PadRight(encryptedB64.Length + (4 - encryptedB64.Length % 4) % 4, '=');
        byte[] raw       = Convert.FromBase64String(padded);
        if (raw[0] != 1) throw new InvalidOperationException($"unsupported wire version: {raw[0]}");

        byte[] salt       = raw[VersionLen..(VersionLen + SaltLen)];
        byte[] nonce      = raw[(VersionLen + SaltLen)..(VersionLen + SaltLen + NonceLen)];
        byte[] ciphertext = raw[(VersionLen + SaltLen + NonceLen)..];

        byte[] ikm    = Encoding.UTF8.GetBytes(secretKey);
        byte[] aesKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, ikm, 32, salt, Encoding.UTF8.GetBytes(HkdfInfo));

        byte[] tag       = ciphertext[^16..];
        byte[] ct        = ciphertext[..^16];
        byte[] plaintext = new byte[ct.Length];

        using var aesgcm = new AesGcm(aesKey, 16);
        aesgcm.Decrypt(nonce, ct, tag, plaintext);

        return JsonSerializer.Deserialize<Dictionary<string, string>>(plaintext)!;
    }

    // Usage
    var creds = DecryptCredentials(SECRET_KEY, response.EncryptedCredentials);
    // Store creds["access_key"], creds["secret_key"], and creds["passphrase"]
    ```
  </Tab>
</Tabs>

## Monitoring approval

Use the `activity_id` from the response to track approval status via [Get Activity](/api-reference/prime-api/rest-api/activities/get-activity-by-activity-id) or [List Activities](/api-reference/prime-api/rest-api/activities/list-activities).

## Error cases

| HTTP | Message                                         | Cause                                                                                                              |
| :--- | :---------------------------------------------- | :----------------------------------------------------------------------------------------------------------------- |
| 400  | `duration must be within allowable timespan`    | `duration_seconds` exceeds the 30-day maximum.                                                                     |
| 400  | `Input duration exceeds current api key expiry` | `duration_seconds` would keep the old key active past its expiry date.                                             |
| 400  | `API key is already rotated.`                   | A rotation is already pending approval. Reject the existing activity via the Prime UI before initiating a new one. |
| 500  | `Sorry, something went wrong.`                  | Unrecoverable error. If your key has expired, create a new key manually through the Prime UI.                      |
