Engineering

Your Data, Your Key: Per-Tenant Encrypted Analytics Exports

Every raw export is sealed with AES-256-GCM under a key derived from your tenant key — and if encryption can't happen, the export refuses to write.

July 2026 • 8 min read

Ask any analytics vendor where your raw event data lives and you'll get a hand-wavy answer about "secure cloud storage." Ask what the file looks like at rest — whose key encrypted it, what happens when encryption fails mid-job, whether an erased visitor can reappear in an old export — and the conversation usually ends.

This post answers those questions for ClickStream's export pipeline, with the actual source. It's the same philosophy as owning your analytics pipeline: your raw events are yours, in an open format, and the way they're protected should be inspectable, not asserted.

The Threat Model for an Encrypted Analytics Export

Raw exports are the most concentrated artifact an analytics platform produces. A single Parquet file can hold tens of thousands of events: page paths, referrers, session and visitor IDs, hashed identifiers, geo fields. Dashboards show aggregates; export files hold the underlying reality.

So the export path gets a stricter design than the rest of the platform:

Key Derivation: HKDF From Your Tenant Key

Each site has an encryption_key stored in the platform's D1 database. The export worker never uses it directly as a cipher key. Instead it runs the tenant key through HKDF-SHA-256 to derive a dedicated AES-256-GCM key, scoped to export encryption by a fixed salt and info string. This is the derivation, verbatim from apps/export-worker/src/services/r2.ts:

async function deriveEncryptionKey(tenantKeyHex: string): Promise<CryptoKey> {
  const keyBytes = new Uint8Array(
    tenantKeyHex.match(/.{2}/g)!.map(byte => parseInt(byte, 16))
  );

  // Import the tenant key as HKDF input
  const baseKey = await crypto.subtle.importKey('raw', keyBytes, 'HKDF', false, ['deriveKey']);

  // Derive AES-256-GCM key using HKDF with a fixed info string
  return crypto.subtle.deriveKey(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt: new TextEncoder().encode('clickstream-r2-export-v1'),
      info: new TextEncoder().encode('aes-256-gcm-export'),
    },
    baseKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

Three details worth noticing:

There's also a deliberately paranoid edge case: if one account has multiple sites with different site-level keys and no account-level key, the export worker throws rather than guessing which key to use. An ambiguous key situation is treated as a failure, not a coin flip.

Unencrypted Writes Abort by Design

The most important property of the pipeline isn't the cipher — it's the failure path. Each Parquet chunk is encrypted before the R2 write, and if that step throws, the entire export dies with a structured error log:

if (encryptionKey) {
  try {
    outputBuffer = await encryptData(outputBuffer, encryptionKey);
    isEncrypted = true;
  } catch (encErr) {
    // ...structured encryption_failure log with clientId, chunk, window...
    throw new Error(
      `[Export] Encryption failed for ${clientId} chunk ${index}: ${errorMessage}. ` +
      `Aborting export — sensitive data must not be written unencrypted.`
    );
  }
}

The same fail-closed rule applies one step earlier: if the worker can't resolve a tenant's encryption key at all, it aborts with "cannot determine encryption requirements" before a single row is queried. Much of ClickStream is deliberately fail-open — the pixel and Signals degrade gracefully because a lost pageview is a small loss. Exports invert that: a plaintext file at rest is a large loss, so the pipeline prefers no file to an unprotected one.

On disk, each encrypted object is a simple, documented layout — a random 96-bit IV generated per file, then the ciphertext, then GCM's authentication tag:

[12-byte IV][ciphertext][16-byte auth tag]

Encrypted objects get an .enc suffix, an application/octet-stream content type, and R2 custom metadata recording encryptionAlgorithm: AES-256-GCM, the event count, and the export window — so you can audit what a file contains without being able to read it.

Hive-Partitioned Parquet Your Warehouse Already Understands

Underneath the encryption, the payload is Apache Parquet with Snappy compression, encoded from Arrow tables. Object keys follow Hive-style partitioning, one hour per leaf:

acme-corp/year=2026/month=07/day=27/hour=14/events.parquet.enc
acme-corp/year=2026/month=07/day=27/hour=14/events.parquet.part1.enc

Design choices that matter when you're the one loading the data:

The format is compatible with BigQuery, Athena, Spark, and DuckDB. To be precise about what that means: there is no BigQuery integration. ClickStream doesn't hold credentials to your warehouse or push data into it. You decrypt with your key, then load the files with your own tooling — bq load, an Athena external table over the partition tree, or DuckDB directly:

-- After decrypting, DuckDB reads the partition tree natively
SELECT event_type, count(*) AS events
FROM read_parquet('exports/**/*.parquet', hive_partitioning = true)
GROUP BY event_type
ORDER BY events DESC;

Decrypting With Your Key

Because the file layout and derivation parameters are documented above, decryption is ~20 lines of standard WebCrypto in Node — no ClickStream SDK required:

import { webcrypto as crypto } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';

// Layout: [12-byte IV][ciphertext + 16-byte GCM auth tag]
async function decryptExport(path, tenantKeyHex) {
  const data = new Uint8Array(await readFile(path));
  const keyBytes = Uint8Array.from(
    tenantKeyHex.match(/.{2}/g).map((b) => parseInt(b, 16))
  );

  const baseKey = await crypto.subtle.importKey('raw', keyBytes, 'HKDF', false, ['deriveKey']);
  const key = await crypto.subtle.deriveKey(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt: new TextEncoder().encode('clickstream-r2-export-v1'),
      info: new TextEncoder().encode('aes-256-gcm-export'),
    },
    baseKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );

  const iv = data.slice(0, 12);
  const ciphertext = data.slice(12);
  const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
  await writeFile(path.replace(/\.enc$/, ''), Buffer.from(plaintext));
}

If the file was tampered with in storage or transit, that decrypt call throws — GCM's auth tag makes corruption loud instead of silent.

Erased Visitors Never Ride Along

An export pipeline can undo a privacy program: a visitor exercises their right to erasure, the platform deletes them from its live stores, and then a raw export happily re-emits their events from the event archive. ClickStream closes that hole with erasure tombstones. Before any export runs, the worker loads every erased visitor for the tenant:

SELECT DISTINCT et.visitor_id
FROM erasure_tombstones et
JOIN sites s ON s.id = et.site_id
WHERE s.client_id = ? AND et.expires_at > ?

Every event row is filtered against that set before it ever reaches the Parquet encoder. An erased visitor doesn't get redacted in the file — they're excluded from it. The broader machinery behind those tombstones is covered in GDPR as cron jobs: compliance you can read in the source.

A 90-Day Retention Window, Enforced Daily

Raw exports live in R2 for 90 days, then a daily cleanup job (midnight UTC) deletes anything older, parsing the date straight out of each object's Hive partition path. The window is a working buffer for you to pull files into your own warehouse — not a shadow archive that accumulates forever. It's the same honest-expiry stance as the session replay retention ladder: data that has a reason to exist has a date it stops existing.

What's Available Today, Honestly

Marketing pages love to describe pipelines in the perpetual present tense. Here's the actual availability:

Export Plan How it works today
CSV export Growth and above Self-serve from the dashboard
Encrypted Parquet export Scale and above Fulfilled on request — contact us with a time window
BigQuery / Athena / warehouse push Does not exist. The format is compatible; the loading is yours

The plan gate is enforced in the worker itself — a client below Scale is skipped with an explicit log line, not silently exported. Automatic hourly Parquet delivery is built and intentionally not yet enabled as a self-serve feature; today each Parquet export run is triggered on request against the window you need. Plan details are on the pricing page.

The Bottom Line

An export you can't decrypt without the vendor is a hostage. An export encrypted so the vendor's storage can't betray you — sealed under your key, in a format any warehouse reads — is actually your data.

Data You Can Actually Take With You

First-party visitor intelligence with open-format, per-tenant encrypted exports — and a live Signals API for the moments before the export lands.

Start free