Back to blog
·7 min read·BitAtlas Team

Encrypted File Chunking for Large Uploads in AI Agent Workflows

How to implement streaming AES-GCM encryption with chunk-level integrity checks and resumable upload protocols for large files in agent workflows.

file chunkinglarge file uploadagent storagestreaming encryptionresumable uploads

AI agents routinely handle files that can't fit in memory: training datasets, video exports, database backups, document corpora. Naively uploading a 2 GB file as a single request is fragile — a network hiccup means starting over, and the entire payload must be held in RAM before encryption can begin. Chunked uploads fix both problems, but the implementation has enough moving parts that developers often skip end-to-end encryption or skip integrity checking. This post walks through doing it right.

Why Chunking and Encryption Are Better Together

The standard objection is: "I'm uploading to an HTTPS endpoint — the transport is encrypted." That's true, but it only protects data in transit. Once the file lands on the server, it typically sits at rest in plaintext unless you've added another layer. If the server is compromised, or the cloud provider's support staff can browse your storage bucket, your users' data is exposed.

Client-side chunked encryption gives you:

  • Streaming encryption — encrypt each chunk before it leaves the process, so the plaintext never touches disk unencrypted and RAM pressure stays constant regardless of total file size.
  • Chunk-level integrity — AES-GCM produces an authentication tag per chunk. You can verify each chunk independently without reassembling the whole file.
  • Safe resumability — a failed upload can resume from the last confirmed chunk, not from byte zero.

The Chunking Model

Before writing any code, settle on the chunk size. Chunks that are too small add round-trip overhead; chunks that are too large waste bandwidth on retries. A chunk size between 5 MB and 16 MB is a reasonable default for most agent workloads.

Each chunk needs its own metadata:

chunk_index  — 0-based position in the sequence
total_chunks — lets the receiver know when assembly is complete
file_id      — ties chunks to a specific upload session
iv           — unique 12-byte initialization vector for this chunk
auth_tag     — 16-byte GCM tag appended after ciphertext

The receiver stores chunks with (file_id, chunk_index) as the composite key. Assembly is just iterating from 0 to total_chunks - 1 and concatenating the decrypted payloads.

Generating Per-Chunk IVs Safely

AES-GCM requires a unique IV for every encryption operation that uses the same key. Reusing an IV with the same key is catastrophic — it leaks the keystream and breaks authenticity. Two safe patterns:

Counter-based: derive the IV from the chunk index using a fixed nonce prefix.

import os, struct

upload_nonce = os.urandom(8)  # 8 bytes, stored with the session

def iv_for_chunk(upload_nonce: bytes, chunk_index: int) -> bytes:
    # 8-byte random prefix + 4-byte big-endian counter = 12 bytes total
    return upload_nonce + struct.pack(">I", chunk_index)

This approach is deterministic, which means you can reconstruct the correct IV for any chunk during retry without storing per-chunk IV state.

Random per-chunk: generate os.urandom(12) for each chunk and store it alongside the ciphertext. This is simpler but requires the IV to travel with the chunk — prepend it to the ciphertext payload.

For agent workflows, the counter-based approach is often preferable because the session state is smaller and retry logic is stateless.

Streaming Encryption in Python

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os, struct

def encrypt_chunk(key: bytes, chunk_index: int, upload_nonce: bytes, plaintext: bytes) -> bytes:
    iv = upload_nonce + struct.pack(">I", chunk_index)
    aesgcm = AESGCM(key)
    ciphertext_with_tag = aesgcm.encrypt(iv, plaintext, None)
    # Returns ciphertext || 16-byte auth tag
    return ciphertext_with_tag

def chunk_file(path: str, chunk_size: int):
    with open(path, "rb") as f:
        index = 0
        while True:
            data = f.read(chunk_size)
            if not data:
                break
            yield index, data
            index += 1

AESGCM.encrypt from the cryptography library appends the authentication tag automatically. The caller never sees plaintext after encryption — the chunk loop reads a window, encrypts it, yields it, and moves on.

Uploading with Retry and Resumability

A robust chunked uploader tracks which chunks have been confirmed server-side. On resume, it asks the server for the list of committed chunk indices and skips them:

import httpx, time

def upload_file(path: str, key: bytes, upload_url: str, chunk_size_bytes: int = 8 * 1024 * 1024):
    upload_nonce = os.urandom(8)
    file_id = os.urandom(16).hex()

    # Pre-scan to get total_chunks without reading entire file
    file_size = os.path.getsize(path)
    total_chunks = -(-file_size // chunk_size_bytes)  # ceiling division

    # Fetch already-committed chunks for resume
    resp = httpx.get(f"{upload_url}/status/{file_id}")
    committed = set(resp.json().get("committed_chunks", []))

    for chunk_index, plaintext in chunk_file(path, chunk_size_bytes):
        if chunk_index in committed:
            continue  # skip already-uploaded chunks

        ciphertext = encrypt_chunk(key, chunk_index, upload_nonce, plaintext)

        payload = {
            "file_id": file_id,
            "chunk_index": chunk_index,
            "total_chunks": total_chunks,
            "iv": (upload_nonce + struct.pack(">I", chunk_index)).hex(),
        }

        for attempt in range(3):
            try:
                r = httpx.post(
                    f"{upload_url}/chunk",
                    content=ciphertext,
                    headers={
                        "X-File-Id": file_id,
                        "X-Chunk-Index": str(chunk_index),
                        "X-Total-Chunks": str(total_chunks),
                        "X-IV": payload["iv"],
                    },
                    timeout=60,
                )
                r.raise_for_status()
                break
            except httpx.HTTPError:
                time.sleep(2 ** attempt)
        else:
            raise RuntimeError(f"Chunk {chunk_index} failed after 3 attempts")

The ceiling-division trick (-(-n // d)) avoids importing math.ceil and works correctly for integer arithmetic.

Server-Side Integrity Verification

The server should verify each chunk before acknowledging it. With AES-GCM, the authentication tag check happens inside decrypt — if the tag doesn't match, the library raises an exception before returning any plaintext.

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag

def receive_chunk(key: bytes, iv_hex: str, ciphertext: bytes) -> bytes:
    iv = bytes.fromhex(iv_hex)
    aesgcm = AESGCM(key)
    try:
        return aesgcm.decrypt(iv, ciphertext, None)
    except InvalidTag:
        raise ValueError("Chunk authentication failed — data may be corrupt or tampered")

Return HTTP 422 on InvalidTag so the agent knows to re-encrypt and re-upload rather than retry the same ciphertext.

Final Assembly

Once committed_chunks equals total_chunks, the server reassembles the file by decrypting and concatenating chunks in order. If you're using BitAtlas for the storage layer, the assembly step happens server-side inside the encrypted vault — your application only needs to call the finalize endpoint with the file_id.

Key Points to Remember

  • Use a unique IV per chunk. Counter-based derivation keeps the session state small and retry logic stateless.
  • AES-GCM authentication tags give you per-chunk integrity for free — verify on receipt, not just on assembly.
  • Store (file_id, chunk_index) as the commit key so the resumable-upload query is a simple indexed lookup.
  • Chunk sizes between 5 MB and 16 MB balance throughput and retry cost for most network conditions.
  • Never send plaintext to the upload endpoint. Encrypt in the agent process before the bytes leave the machine.

Large file handling is where many "encrypted storage" solutions quietly fall back to plaintext for performance reasons. Streaming AES-GCM with per-chunk tags makes it possible to stay encrypted through the entire pipeline without loading the whole file into memory.

Encrypt your agent's data today

BitAtlas gives your AI agents AES-256-GCM encrypted storage with zero-knowledge guarantees. Free tier, no credit card required.