# How OpenSEO Handles GDPR Storage Erasure Requests

> Learn how OpenSEO handles GDPR storage erasure requests. Discover its secure API, Zod schema validation, and cross-platform deletion for OAuth tokens, Durable Objects, KV stores, and R2.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: how-to-guide
- Published: 2026-08-19

---

**OpenSEO fulfills GDPR storage erasure requests through an authenticated internal API endpoint that verifies HMAC-SHA-256 signatures, validates every payload field with a Zod schema, and then coordinates deletion across active workflows, Google OAuth tokens, Durable Objects, KV stores, and Cloudflare R2.**

OpenSEO, the open-source SEO toolkit maintained in the `every-app/open-seo` repository, implements a strict, code-defined pipeline for handling GDPR storage erasure requests. The process is governed by [`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts) and [`src/shared/gdpr-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/gdpr-erasure.ts), which together enforce cryptographic authentication, schema validation, and exhaustive data removal.

## Secure Request Authentication

The erasure flow begins at the internal POST endpoint `/api/internal/gdpr-erasure/storage`, defined in [`src/shared/gdpr-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/gdpr-erasure.ts). Every incoming request must carry two custom headers: `x-gdpr-timestamp` and `x-gdpr-signature`.

The `authenticateRequest` helper constructs an HMAC-SHA-256 signature by calling `signGdprErasureRequest` in [`src/shared/gdpr-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/gdpr-erasure.ts) (lines 42–61). It derives the signature from the secret stored in `env.GDPR_ERASURE_SECRET`, the timestamp header, and the raw request body. The server then compares this value against the client-supplied header using Node.js `timingSafeEqual` to block timing side-channel attacks ([`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts), lines 27–35).

OpenSEO also applies hard window and size limits. Any request older than five minutes or carrying a payload larger than 5 MiB is rejected immediately ([`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts), lines 46–66).

## Payload Validation

After authentication, the request body is parsed against `gdprStorageErasurePayloadSchema`, a strict Zod schema exported from [`src/shared/gdpr-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/gdpr-erasure.ts) (lines 10–30). The schema enumerates every data collection that can hold personal information, including:

- `userId` and `email`
- `organizationIds` and `projectIds`
- Durable Object session IDs
- `auditIds` and active workflow IDs
- `r2Keys`
- Google OAuth accounts

If the payload fails validation, the endpoint returns a `400 Bad Request` response with the message `Invalid erasure payload`. Only a fully validated payload proceeds to deletion.

## Comprehensive Data Removal

Once authenticated and parsed, the `eraseStorage` function in [`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts) (lines 22–41) executes a coordinated cleanup across all persistence layers. It aggregates a result object summarizing deletions per category and returns it to the caller.

### Active Workflows

To prevent in-flight jobs from writing data after the wipe, `terminateWorkflows` iterates over every active crawl and rank-check workflow ID and invokes each workflow’s `terminate()` method. This step is implemented in [`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts) (lines 58–73).

### Google OAuth Tokens

For each Google account listed in the payload, `revokeGoogleAccount` requests a fresh access token through OpenSEO’s internal auth service and then posts it to `https://oauth2.googleapis.com/revoke`. If a token cannot be minted, the entry is marked as `token_unavailable` so operators know revocation was deferred. The logic resides in [`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts) (lines 36–70).

### Durable Objects

OpenSEO targets two Durable Object namespaces during erasure: **SamChat** and **OnboardingChat**. Each instance is addressed through its namespace binding and receives a `destroyForErasure()` call. You can review the namespace lookups in [`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts) (lines 92–96 for SamChat and lines 98–104 for OnboardingChat).

### Audit Scratchpads and KV Progress Markers

For every `auditId` in the payload, the system calls `getAuditScratchpad(...).destroyForErasure()` to remove the temporary database row. It also deletes the matching KV key `audit-progress:<auditId>` from Cloudflare KV. This cleanup is handled early in the erasure sequence ([`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts), lines 5–9).

### R2 Objects

Personal data stored in Cloudflare R2 is deleted in batches of up to 1,000 keys per operation. The function invokes `env.R2.delete` with the batched key list, as shown in [`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts) (lines 14–17).

### Prompt-Cache Objects

Per-organization prompt caches are stored under the prefix defined by `AI_SEARCH_PROMPT_CACHE_NAMESPACE`. The `deleteOrganizationPromptCaches` routine enumerates every object under that prefix and deletes those whose metadata matches any of the supplied `organizationIds`. The implementation appears in [`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts) (lines 10–34).

### OAuth Grants and Tokens in KV

All KV keys prefixed with `grant:<userId>:` are listed via `deleteOauthGrants`, and each grant’s associated token keys are removed via `deleteKvPrefix`. This ensures that OAuth authorizations and refresh tokens are fully purged from the edge cache. See [`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts) (lines 88–108).

## Triggering Erasure with the CLI Script

Operators typically initiate a GDPR storage erasure request through the CLI script at [`scripts/erase-user-data.ts`](https://github.com/every-app/open-seo/blob/main/scripts/erase-user-data.ts). The script queries the database for every ID associated with the target user, assembles the payload, and signs the request using `signGdprErasureRequest`. It then posts to the internal endpoint with the required HMAC headers. The script also respects a 30-day Cloudflare state-retention window for any remaining in-flight requests ([`scripts/erase-user-data.ts`](https://github.com/every-app/open-seo/blob/main/scripts/erase-user-data.ts), lines 150–165).

You can run the script directly:

```bash
pnpm gdpr:erase-user --email user@example.com \
    --secret $GDPR_ERASURE_SECRET

```

Or send a signed request manually from Node.js:

```javascript
import fs from "fs";
import crypto from "crypto";
import fetch from "node-fetch";

const secret = "YOUR_GDPR_ERASURE_SECRET";
const payload = JSON.stringify({
  userId: "12345",
  email: "user@example.com",
  organizationIds: ["org-1"],
  projectIds: ["proj-1"],
  samSessionIds: [],
  auditIds: [],
  activeAuditWorkflowIds: [],
  activeRankWorkflowIds: [],
  r2Keys: [],
  googleAccounts: [],
});

const timestamp = Date.now().toString();
const hmac = crypto.createHmac("sha256", secret)
                 .update(`${timestamp}.${payload}`)
                 .digest("hex");

await fetch("https://your-open-seo-instance.com/api/internal/gdpr-erasure/storage", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Content-Length": Buffer.byteLength(payload).toString(),
    "x-gdpr-timestamp": timestamp,
    "x-gdpr-signature": hmac,
  },
  body: payload,
});

```

## Summary

- OpenSEO exposes a single internal endpoint, `/api/internal/gdpr-erasure/storage`, protected by HMAC-SHA-256 signing and `timingSafeEqual` verification.
- Every payload is strictly validated against the `gdprStorageErasurePayloadSchema` Zod schema before any data is touched.
- The `eraseStorage` routine removes personal data from seven distinct backends: active workflows, Google OAuth tokens, Durable Objects, audit scratchpads, Cloudflare KV, Cloudflare R2, and prompt caches.
- Operators invoke the process safely through [`scripts/erase-user-data.ts`](https://github.com/every-app/open-seo/blob/main/scripts/erase-user-data.ts), which handles payload assembly, signing, and the 30-day retention window.

## Frequently Asked Questions

### What authentication mechanism protects OpenSEO's GDPR erasure endpoint?

The endpoint requires a signed POST request carrying `x-gdpr-timestamp` and `x-gdpr-signature` headers. The server rebuilds an HMAC-SHA-256 signature in `signGdprErasureRequest` and compares it with `timingSafeEqual` to prevent timing attacks. Requests older than five minutes or exceeding 5 MiB are rejected.

### Which storage backends are cleaned during a GDPR storage erasure request in OpenSEO?

A single erasure request triggers cleanup across seven storage backends. These include active workflows, Google OAuth tokens, Durable Objects, audit scratchpads and KV markers, R2 object batches, prompt caches, and OAuth grants stored in KV.

### How does OpenSEO handle active workflows during a GDPR erasure?

The `terminateWorkflows` function in [`src/server/gdpr/storage-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/server/gdpr/storage-erasure.ts) calls `terminate()` on each active crawl or rank-check workflow. This stops in-flight jobs before storage layers are wiped, preventing post-erasure writes.

### Can Google OAuth tokens be revoked if they are already expired?

Yes. `revokeGoogleAccount` attempts to mint a fresh access token through the internal auth service before calling the Google revocation endpoint. If the token cannot be refreshed, the system marks the account as `token_unavailable` instead of failing silently, giving operators a clear audit trail.