Decrypting encrypted forms

On an end-to-end encrypted form, webhooks and n8n receive the sealed submission instead of its fields. Here is how to open it in your own systems.

What arrives

{
  "form": { "id": "6f1c7a52-…", "name": "Clinic intake" },
  "submission": {
    "id": 42, "createdAt": "2026-10-11T15:47:24Z", "domain": "https://example.com",
    "encrypted": true,
    "sealed": { "v": 1, "kid": "k1", "epk": "BGD+…", "iv": "BwcH…", "ct": "eIOF…" }
  }
}

The API, the MCP tools and intake-cli listen return the same sealed object.

1. Export the private key

An owner or admin downloads it under Settings → Encryption → Export the private key, with the vault passphrase. The export is recorded in the audit log. The file, intake-vault-keys.json, holds every key in the vault:

{ "workspace": "…", "keys": [ { "kid": "k1", "fingerprint": "18ED BF11 …", "privateKeyPkcs8": "MIGHAgEA…" } ] }

Anyone with this file can read every encrypted submission. Keep it in a secrets manager, give it only to the system that needs it, and never commit it to source control.

2. Open the envelope

The format uses standard cryptography: ECDH on P-256 between your private key and epk, then HKDF-SHA256 (empty salt, info intake-e2e-v1|<form id>|<kid>) to an AES-256-GCM key, then decrypt ct with iv and the same string as additional data. The result is the submission's fields as JSON. The form id is part of the key, so an envelope cannot be passed off as another form's.

Node.js (18 or later)

// node decrypt-intake.mjs intake-vault-keys.json < delivery.json
import { webcrypto } from "node:crypto";
import { readFileSync } from "node:fs";

const keyFile = JSON.parse(readFileSync(process.argv[2], "utf8"));
const { subtle } = webcrypto;
const EC = { name: "ECDH", namedCurve: "P-256" };
const b64 = (s) => Buffer.from(s, "base64");

export async function openSealed(formId, env) {
  const k = keyFile.keys.find((k) => k.kid === env.kid);
  if (!k) throw new Error(`no key ${env.kid} in the key file`);
  const priv = await subtle.importKey("pkcs8", b64(k.privateKeyPkcs8), EC, false, ["deriveBits"]);
  const epk = await subtle.importKey("raw", b64(env.epk), EC, false, []);
  const shared = await subtle.deriveBits({ name: "ECDH", public: epk }, priv, 256);
  const info = new TextEncoder().encode(`intake-e2e-v1|${formId}|${env.kid}`);
  const hkdf = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
  const key = await subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info },
    hkdf, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
  const data = await subtle.decrypt({ name: "AES-GCM", iv: b64(env.iv), additionalData: info }, key, b64(env.ct));
  return JSON.parse(new TextDecoder().decode(data));
}

const delivery = JSON.parse(readFileSync(0, "utf8")); // a webhook or n8n body
console.log(await openSealed(delivery.form.id, delivery.submission.sealed));

Python

# python decrypt_intake.py intake-vault-keys.json < delivery.json   (pip install cryptography)
import base64, json, sys
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

def open_sealed(key_file, form_id, env):
    k = next(k for k in key_file["keys"] if k["kid"] == env["kid"])
    priv = serialization.load_der_private_key(base64.b64decode(k["privateKeyPkcs8"]), password=None)
    epk = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), base64.b64decode(env["epk"]))
    info = f"intake-e2e-v1|{form_id}|{env['kid']}".encode()
    key = HKDF(algorithm=hashes.SHA256(), length=32, salt=b"", info=info).derive(priv.exchange(ec.ECDH(), epk))
    data = AESGCM(key).decrypt(base64.b64decode(env["iv"]), base64.b64decode(env["ct"]), info)
    return json.loads(data)

key_file = json.load(open(sys.argv[1]))
delivery = json.load(sys.stdin)  # a webhook or n8n body
print(open_sealed(key_file, delivery["form"]["id"], delivery["submission"]["sealed"]))

In n8n, a Code node can run the same steps if your n8n allows the built-in crypto module (NODE_FUNCTION_ALLOW_BUILTIN=crypto); keep the key file's contents in a credential, not in the workflow.

3. Encrypted files

Each file is encrypted in the visitor's browser with its own random AES-256-GCM key. In the opened data, the field holds an entry instead of the file: {"$file": "<reference>", "name", "type", "size", "key", "iv"}. The delivery's files list gives each stored file's field (the same reference) and a url to download its ciphertext. Decrypt it with the entry's key and iv, and intake-file-v1|<form id>|<reference> as additional data. Intake never sees the name, type or contents, so encrypted files cannot be virus-scanned: treat them as you would an email attachment.

Node.js

export async function openFile(formId, entry, ciphertext) {
  const key = await subtle.importKey("raw", b64(entry.key), "AES-GCM", false, ["decrypt"]);
  const additionalData = new TextEncoder().encode(`intake-file-v1|${formId}|${entry.$file}`);
  return Buffer.from(await subtle.decrypt({ name: "AES-GCM", iv: b64(entry.iv), additionalData }, key, ciphertext));
}

const data = await openSealed(delivery.form.id, delivery.submission.sealed);
for (const f of delivery.files) {
  const entry = Object.values(data).flat().find((v) => v?.$file === f.field);
  const ciphertext = Buffer.from(await (await fetch(f.url)).arrayBuffer());
  const bytes = await openFile(delivery.form.id, entry, ciphertext); // save as entry.name
}

Python

def open_file(form_id, entry, ciphertext):
    aad = f"intake-file-v1|{form_id}|{entry['$file']}".encode()
    return AESGCM(base64.b64decode(entry["key"])).decrypt(base64.b64decode(entry["iv"]), ciphertext, aad)

Whole exports

To open every encrypted submission in a workspace export at once, use intake-cli decrypt with the vault passphrase or recovery code. No key file is needed. Add --files DIR to decrypt the encrypted files into a folder as well.