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

# Encryption

> Encrypted streams keep record contents private at rest with a per-request key that S2 does not store.

S2 supports stream encryption with a key that you supply at request time. Configure a basin with a cipher for new streams, then provide the same key on `append` and `read` on any new streams.

<CardGroup cols={1}>
  <Card title="Determined at stream creation">
    A stream captures its `cipher` when it is created, through its basin's configuration.
  </Card>

  <Card title="Key supplied on data plane requests">
    Encrypted streams require the `s2-encryption-key` header on REST requests, or pass it using the corresponding CLI environment variable or flags.
  </Card>

  <Card title="Key custody stays with you">
    S2 does not persist or log the encryption key.
  </Card>
</CardGroup>

## How it works

```mermaid theme={null}
graph LR
  A["Set basin stream_cipher"] --> B["Create stream"]
  B --> C["Stream requires that cipher for its lifetime"]
  C --> D["Append with s2-encryption-key"]
  C --> E["Read with the same s2-encryption-key"]
```

New streams inherit the `stream_cipher` configured on the basin at the moment they are created. Reconfiguring the basin later only affects streams created after that change.

## What is encrypted

For regular records, S2 encrypts headers and body before storage, and decrypts them only when serving a read with the correct key.

[Command records](/concepts/command-records) stay plaintext because S2 must interpret them to apply service-side operations such as fencing and trimming.

<Info>
  Encrypted records are cryptographically bound to the stream they were written to. S2 includes the stream's unique identifier as associated data, so ciphertext from one stream cannot be accepted as a record in another stream.
</Info>

## Supported ciphers

| Cipher                            | API / CLI value | Key length |
| --------------------------------- | --------------- | ---------- |
| **AEGIS-256** (*recommended*)     | `aegis-256`     | 32 bytes   |
| **AES-256-GCM** (*NIST-approved*) | `aes-256-gcm`   | 32 bytes   |

Keys are base64-encoded when provided in the `s2-encryption-key` header or to the CLI. SDKs allow providing either a string or bytes.

<Note>
  A stream configured with `aes-256-gcm` can have up to 2^32 records (\~4.3 billion), and subsequent appends will be rejected. Otherwise, a stream can have up to 2^64 records (effectively unlimited) – so `aegis-256` is generally preferable.
</Note>

## Where keys are required

| Operation                   | Key required?                     | Notes                                                         |
| --------------------------- | --------------------------------- | ------------------------------------------------------------- |
| Create or reconfigure basin | No                                | Set `stream_cipher` here for future streams.                  |
| Create stream               | No                                | The stream inherits the basin's current `stream_cipher`.      |
| Append                      | Yes, if the stream has a `cipher` | Provide the key on every request or session.                  |
| Read                        | Yes, if the stream has a `cipher` | Use the same key that encrypted the records you want to read. |
| Check tail                  | No                                | Tail only returns positions, not payloads.                    |
| List streams                | No                                | Each stream's `cipher` is returned in metadata.               |

## Generate a key

```bash theme={null}
openssl rand -base64 32
```

<Note>
  Store the resulting value as a secret to be used for one or more streams. Maintain a mapping of which key was used for which stream, so that it may be specified consistently for appends and reads.
</Note>

## Key management

For coarse-grained access, manage a shared key in your existing Key Management System (KMS) or secrets infrastructure.

For finer-grained access, such as a key per stream, use envelope encryption:

* Keep a Key Encryption Key (KEK) in your KMS.
* Generate a Data Encryption Key (DEK) per stream, and store the wrapped DEK alongside your stream metadata.
* At request time, your application unwraps the DEK and passes it to S2 as the stream encryption key.

If you need to rotate the key, create a new stream and cut writers and readers over deliberately. Similarly, to change the cipher used, update the basin configuration and then create a new stream.

<Warning>
  Do not rotate a stream key in place by simply changing the request header. S2 will accept the append, but later reads with only one key will fail once they reach records encrypted under a different key.
</Warning>

## Configure encryption for new streams

<Tabs>
  <Tab title="CLI">
    ```bash theme={null}
    s2 create-basin secure-events \
      --stream-cipher aegis-256

    # or change the default for streams created later
    s2 reconfigure-basin secure-events \
      --stream-cipher aes-256-gcm
    ```
  </Tab>

  <Tab title="REST">
    ```bash theme={null}
    curl --request POST \
      --url 'https://aws.s2.dev/v1/basins' \
      --header "Authorization: Bearer ${S2_ACCESS_TOKEN}" \
      --header 'Content-Type: application/json' \
      --data '{
        "basin": "secure-events",
        "config": {
          "stream_cipher": "aegis-256"
        }
      }'
    ```

    ```bash theme={null}
    curl --request PATCH \
      --url 'https://aws.s2.dev/v1/basins/secure-events' \
      --header "Authorization: Bearer ${S2_ACCESS_TOKEN}" \
      --header 'Content-Type: application/json' \
      --data '{
        "stream_cipher": "aes-256-gcm"
      }'
    ```
  </Tab>

  <Tab title="TypeScript">
    ```typescript theme={null}
    await client.basins
    	.create({
    		basin: basinName,
    		config: { streamCipher: "aegis-256" },
    	})
    	.catch((e) => {
    		if (!(e instanceof S2Error && e.status === 409)) throw e;
    	});

    await client.basins.reconfigure({
    	basin: basinName,
    	streamCipher: "aes-256-gcm",
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    await client.create_basin(
        basin_name,
        config=BasinConfig(stream_cipher=Encryption.AEGIS_256),
    )

    await client.reconfigure_basin(
        basin_name,
        config=BasinConfig(stream_cipher=Encryption.AES_256_GCM),
    )
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    client.Basins.Create(ctx, s2.CreateBasinArgs{
    	Basin: basinName,
    	Config: &s2.BasinConfig{
    		StreamCipher: s2.Ptr(s2.EncryptionAlgorithmAegis256),
    	},
    })

    client.Basins.Reconfigure(ctx, s2.ReconfigureBasinArgs{
    	Basin: basinName,
    	Config: s2.BasinReconfiguration{
    		StreamCipher: s2.Ptr(s2.EncryptionAlgorithmAes256Gcm),
    	},
    })
    ```
  </Tab>

  <Tab title="Rust">
    ```rust theme={null}
    client
        .create_basin(
            CreateBasinInput::new(basin_name.clone())
                .with_config(BasinConfig::new().with_stream_cipher(EncryptionAlgorithm::Aegis256)),
        )
        .await?;

    client
        .reconfigure_basin(ReconfigureBasinInput::new(
            basin_name.clone(),
            BasinReconfiguration::new().with_stream_cipher(EncryptionAlgorithm::Aes256Gcm),
        ))
        .await?;
    ```
  </Tab>
</Tabs>

<Note>
  Stream create endpoints do not take a per-stream encryption field. A stream inherits the basin default when it is created, and that `cipher` is immutable for the lifetime of the stream.
</Note>

## Append and read with the key

<Tabs>
  <Tab title="CLI">
    ```bash theme={null}
    export S2_ENCRYPTION_KEY="$(openssl rand -base64 32)"

    printf 'top secret\n' | s2 append s2://secure-events/audit-log
    s2 read s2://secure-events/audit-log -s 0 -n 1
    ```

    ```bash theme={null}
    # Alternatively, read the key from a file
    s2 read s2://secure-events/audit-log \
      --encryption-key-file ./stream-key.b64 \
      -s 0 -n 10
    ```
  </Tab>

  <Tab title="REST">
    ```bash theme={null}
    curl --request POST \
      --url 'https://secure-events.b.s2.dev/v1/streams/audit-log/records' \
      --header "Authorization: Bearer ${S2_ACCESS_TOKEN}" \
      --header 'Content-Type: application/json' \
      --header 's2-format: raw' \
      --header "s2-encryption-key: ${S2_ENCRYPTION_KEY}" \
      --data '{
        "records": [
          { "body": "top secret" }
        ]
      }'
    ```

    ```bash theme={null}
    curl --request GET \
      --url 'https://secure-events.b.s2.dev/v1/streams/audit-log/records?seq_num=0&count=10' \
      --header "Authorization: Bearer ${S2_ACCESS_TOKEN}" \
      --header "s2-encryption-key: ${S2_ENCRYPTION_KEY}"
    ```
  </Tab>

  <Tab title="TypeScript">
    ```typescript theme={null}
    const stream = basin.stream(streamName, { encryptionKey: encKey });

    await stream.append(
    	AppendInput.create([AppendRecord.string({ body: "top secret" })]),
    );

    const batch = await stream.read({
    	start: { from: { seqNum: 0 } },
    	stop: { limits: { count: 10 } },
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    stream = basin.stream(
        stream_name,
        encryption_key=os.environ["S2_ENCRYPTION_KEY"],
    )

    await stream.append(
        AppendInput(
            records=[
                Record(body=b"top secret"),
            ]
        )
    )

    batch = await stream.read(
        start=SeqNum(0),
        limit=ReadLimit(count=10),
    )
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    key, err := s2.NewEncryptionKey(os.Getenv("S2_ENCRYPTION_KEY"))
    if err != nil {
    	log.Fatalf("S2_ENCRYPTION_KEY: %v", err)
    }

    stream := basin.StreamWithOptions(s2.StreamName(streamName), &s2.StreamOptions{
    	EncryptionKey: &key,
    })

    _, _ = stream.Append(ctx, &s2.AppendInput{
    	Records: []s2.AppendRecord{
    		{Body: []byte("top secret")},
    	},
    })

    batch, _ := stream.Read(ctx, &s2.ReadOptions{
    	SeqNum: s2.Uint64(0),
    	Count:  s2.Uint64(10),
    })
    ```
  </Tab>

  <Tab title="Rust">
    ```rust theme={null}
    let stream = basin
        .stream(stream_name.clone())
        .with_encryption_key(std::env::var("S2_ENCRYPTION_KEY")?.parse()?);

    stream
        .append(AppendInput::new(AppendRecordBatch::try_from_iter([
            AppendRecord::new("top secret")?,
        ])?))
        .await?;

    let batch = stream
        .read(
            ReadInput::new()
                .with_start(ReadStart::new().with_from(ReadFrom::SeqNum(0)))
                .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(10))),
        )
        .await?;
    ```
  </Tab>
</Tabs>

<Note>
  The same keying pattern applies to single-request operations, append/read sessions, SSE reads, and S2S sessions. The encryption key is just an HTTP header on the wire.
</Note>

## Inspect cipher config

* [Get basin config](/api/basins/get-config) to see the basin's current `stream_cipher` for new streams.
* [List streams](/api/streams/list) to inspect current streams' `cipher`.

## Errors

| Situation                               | Result                  |
| --------------------------------------- | ----------------------- |
| Missing key on an encrypted stream      | `422 invalid`           |
| Key is not valid base64 or not 32 bytes | `422 invalid`           |
| Wrong key                               | `400 decryption_failed` |
