How your chats stay yours:
end-to-end encryption, explained
A look under the hood at how Yapd protects a saved wrap. Plain language first, then the cryptographic design, what our encryption covers, our real proxy source, and our compliance posture, so you can trust it with confidence.
- Anonymous wraps are never uploaded. They live and die in your browser.
- Saved wraps are encrypted on your device with AES-256-GCM before upload. We store ciphertext only.
- The master key is wrapped by a PBKDF2 passphrase factor and/or a WebAuthn PRF passkey. It is held in memory for the session and never sent to us.
- The server only ever sees ciphertext, a random IV, non-identifying counts, and a one-way chat fingerprint hash.
- Encore's AI runs on Anthropic's API: SOC 2 Type II, ISO 27001/42001, no training on your data, HIPAA BAA available. Our proxy stores nothing.
- You hold the only key. Your passphrase (or an enrolled device) is what unlocks your wraps, so keep it somewhere safe.
Imagine your wrap is a diary. You put it in a magic box and click it shut. Only your thumb can open that box. Nobody else can, not even the person who built the box.
You hand the closed box to us to keep safe. We can carry it, stack it, and give it back to you later, but we can never look inside. When you want to read your diary, the box opens in your hands, not ours.
That is end-to-end encryption. The grown-up version is below.
Scroll. The diagram below advances through each stage.
It starts on your phone
Your exported chat is read and analysed entirely inside your browser. The words never leave your device in a form anyone else could read.
Every saved wrap is serialized to JSON (the recap, a sampled slice of messages, and any AI insights) and encrypted in the browser with the Web Crypto API before a single byte is sent. The primitives:
GCM is an authenticated mode, so a tampered ciphertext does not decrypt to garbage, it fails loudly. A fresh random IV per blob means encrypting the same wrap twice never produces the same bytes, which prevents an observer from correlating records.
The master key is what actually encrypts your wraps. It is never sent to the server in any form we could use. Instead we store wrapped copies of it, called envelopes. Each envelope is the master key encrypted under a different factor:
- PBKDF2-HMAC-SHA-256
- 600,000 iterations
- 16-byte random salt
- Derives a non-extractable AES-GCM key that wraps the master
- WebAuthn PRF extension
- Passkey-derived secret (Face ID / Touch ID / Hello)
- One envelope per enrolled device
- Hardware-bound, phishing-resistant
Any registered factor can unwrap the master key, which is what makes recovery possible: enter your passphrase on a new device, unwrap the master, then enroll that device's passkey as a new envelope. The unwrapped master key lives only in JavaScript memory for the session. It is never written to localStorage, sessionStorage, IndexedDB, or a cookie. Closing the tab evicts it.
For a fast library, the decrypted recap stats (not the key, not the messages) are cached in IndexedDB. The worst case if that device store leaks is exposure of aggregate stats for wraps you already own, never the message content and never the key.
- AES-GCM ciphertext and its random IV
- cryptoVersion integer
- Message count, participant count
- Relationship type, theme id
- A one-way chat fingerprint: sha256(sorted participant names + first-message year-month), truncated
- Your Google account name and email
- Any message text
- The chat title or participant names
- The AI Encore report contents
- The master key or any usable form of it
- Anything that would let us decrypt the above
The fingerprint is deliberately one-way. It lets us link a purchase or an Encore report to the right chat without learning who is in it. You cannot reverse a SHA-256 hash, and you cannot confirm a guess without already knowing the exact participant names and the month the chat began.
We believe in being clear about exactly what protection you get, so you can use Yapd with confidence.
- A database breach or a stolen backup
- Access by Yapd staff, or a third party we are compelled to share with
- Compromise of our storage or hosting infrastructure
- Anyone curious about the contents of your chats
- Keep your own device free of malware, the same as for any app
- A public link you choose to create is, by design, viewable by anyone you share it with
- Your passphrase is the only way in, so store it safely
- Anything you screenshot and share is in your hands
To generate an Encore, your content streams from your browser through a thin pass-through to Anthropic. It stays in memory only for the moment the request is in flight and is never written to a log, a database, or an analytics event. The report is then encrypted on your device before it is saved, so we never see the content or the result. The entire proxy, read straight from our codebase, is below:
// POST /api/encore-proxy
//
// This file is published, in full and unedited, on our security blog.
// If you are reading it there: this is the real, shipping source. The
// comments are written so a non-engineer can verify our claims too.
//
// What this route is: a thin pass-through that forwards an Encore AI
// request from your browser to Anthropic and streams the answer back.
// Anthropic's API cannot be called directly from a browser without
// exposing our API key to the world, so the request has to pass through
// our server. This file is the entire amount of code that touches it.
//
// What we promise here, line by line below:
// 1. Your request *content* is forwarded to Anthropic and nowhere
// else. It is never written to a database, a log line, an
// analytics event, or an error tracker. The only things we record
// are counters: an abuse counter (a number keyed to a hash of your
// account id) and a per-generation call counter on the paid Encore
// grant that proves the request belongs to a real purchase. Both
// hold only numbers and timestamps — never your content. The grant
// id arrives in a request *header*, so we still never read the body.
// 2. We never read, parse, or transform what you send. We move bytes.
// 3. Your data is only ever in memory, only for the seconds the
// request is in flight. Nothing about it is kept afterwards.
// 4. The Anthropic API key stays on the server and is never sent to
// the browser.
//
// This route is live: Encore generation runs in your browser and uses
// this proxy to reach Anthropic. The browser never sees our API key,
// and this proxy never stores, logs, reads, or transforms what passes
// through it — your content is in memory only for the seconds the
// request is in flight, and the finished report is encrypted on your
// device before it is ever saved. The safeguard shipped before the
// feature; the feature is now on, and the guarantee is unchanged.
import { createHash } from "node:crypto";
import { requireActiveUserId } from "@/server/auth";
import { prisma } from "@/server/db";
import {
ENCORE_GLOBAL_RULE,
ENCORE_PROXY_RULE,
GLOBAL_BUCKET,
enforceRateLimit,
rateLimitedResponse,
} from "@/server/rate-limit";
export const runtime = "nodejs";
// Never cache. Every request is a fresh model call, never reused.
export const dynamic = "force-dynamic";
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
const ANTHROPIC_VERSION = "2023-06-01";
// Request-size ceiling. The body is the main per-call cost lever an
// authenticated caller controls (a giant prompt = a giant Anthropic
// bill, which the call-count rate limit alone does not bound). We cap
// it from the Content-Length header *without reading the body*, so the
// "this proxy never reads, parses, or transforms what you send"
// guarantee above is fully preserved. An Encore request is a recap-
// sized JSON; this is far above any legitimate one. (Note: this bounds
// input size only — model choice and max_tokens are still caller-
// controlled because constraining them would require parsing the body
// and breaking that guarantee. Revisit if cost abuse is observed.)
const MAX_ENCORE_PROXY_BODY_BYTES = 4 * 1024 * 1024; // 4 MB
export async function POST(req: Request): Promise<Response> {
// Step 1: only signed-in, active users can use this route, so it
// cannot be abused as a free public relay to Anthropic. We resolve
// the user against the database (not just the token) so a blocked or
// deleted account can't keep relaying paid calls on a stale session.
// We read only the session/account here — never the request body.
const userId = await requireActiveUserId();
if (!userId) {
return new Response(JSON.stringify({ error: "Not signed in" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Step 1.5: paid-charge capability check. This is the gate that binds
// an Anthropic call to a real Encore credit spend. chargeEncoreCredits
// (which atomically debits credits) mints an EncoreProxyGrant bound to
// this user + purchase with a hard call ceiling; the browser sends its
// id in the `x-encore-grant` HEADER (never the body — the no-read
// guarantee is intact). We atomically consume one unit: the UPDATE
// lands only if the grant exists, is owned by THIS live account, still
// has budget, and hasn't expired. No valid grant ⇒ 402 and we forward
// nothing — so a signed-in account that never paid can't relay to
// Anthropic, and a single purchase can't exceed its call ceiling. This
// runs BEFORE the rate-limit counters so uncharged/abusive traffic is
// shed without ever consuming the shared global ceiling (which was a
// cheap whole-feature DoS lever).
const grantToken = req.headers.get("x-encore-grant") ?? "";
if (!grantToken) {
return new Response(
JSON.stringify({ error: "Missing Encore session." }),
{ status: 402, headers: { "Content-Type": "application/json" } },
);
}
let consumed: Array<{ callsUsed: number }>;
try {
consumed = await prisma.$queryRaw<Array<{ callsUsed: number }>>`
UPDATE "EncoreProxyGrant"
SET "callsUsed" = "callsUsed" + 1
WHERE "id" = ${grantToken}
AND "userId" = ${userId}
AND "callsUsed" < "callsMax"
AND "expiresAt" > now()
RETURNING "callsUsed";
`;
} catch {
// Fail closed: if the grant store is unreachable we cannot prove the
// caller paid, so we must not relay an Anthropic call on trust.
return new Response(
JSON.stringify({ error: "Encore is temporarily unavailable." }),
{ status: 503, headers: { "Content-Type": "application/json" } },
);
}
if (consumed.length === 0) {
// Unknown / not-owned / exhausted / expired grant.
return new Response(
JSON.stringify({ error: "Encore session expired or exhausted." }),
{ status: 402, headers: { "Content-Type": "application/json" } },
);
}
// Step 1.6: per-account cost guard. Even within a valid grant, bound
// how fast one account can loop across purchases in a rolling window.
// Bucket key is a hash of the account id — only a number is stored,
// never any part of your request.
const rl = await enforceRateLimit(
ENCORE_PROXY_RULE,
createHash("sha256").update(userId).digest("hex").slice(0, 32),
);
if (!rl.ok) return rateLimitedResponse(rl);
// Step 1.65: a route-wide ceiling. Only paid, in-budget calls reach
// here (uncharged traffic was already shed above), so this backstops
// total spend across all callers without being trippable by cheap
// unpaid requests.
const globalRl = await enforceRateLimit(ENCORE_GLOBAL_RULE, GLOBAL_BUCKET, {
failClosed: true,
});
if (!globalRl.ok) return rateLimitedResponse(globalRl);
// Step 1.7: size guard. Reject an over-large request from its declared
// Content-Length *before* forwarding — we inspect the header only,
// never the body, so the no-read guarantee stands. This caps the
// input-cost lever the call-count limiter doesn't.
const declaredLen = Number(req.headers.get("content-length") ?? 0);
if (Number.isFinite(declaredLen) && declaredLen > MAX_ENCORE_PROXY_BODY_BYTES) {
return new Response(JSON.stringify({ error: "Request too large." }), {
status: 413,
headers: { "Content-Type": "application/json" },
});
}
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return new Response(
JSON.stringify({ error: "Encore AI is not configured." }),
{ status: 503, headers: { "Content-Type": "application/json" } },
);
}
// Step 2: hand the request body straight to Anthropic without ever
// reading it. We never call req.json() or req.text(), because doing so
// would load your content into memory where it could be logged. We
// pass the stream through untouched.
let upstream: Response;
try {
upstream = await fetch(ANTHROPIC_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": ANTHROPIC_VERSION,
},
body: req.body,
// Required by the runtime when streaming a request body through.
// @ts-expect-error duplex is valid at runtime but absent from the types.
duplex: "half",
});
} catch {
return new Response(
JSON.stringify({ error: "Upstream request failed." }),
{ status: 502, headers: { "Content-Type": "application/json" } },
);
}
// Step 3: stream Anthropic's reply straight back to your browser,
// again without reading or storing any of it.
return new Response(upstream.body, {
status: upstream.status,
headers: {
"Content-Type":
upstream.headers.get("Content-Type") ?? "application/json",
"Cache-Control": "no-store",
},
});
}
Encore's AI runs on Anthropic's API, chosen for its strong enterprise security and privacy commitments:
Independently audited security controls for the API.
Certified information-security and AI-management systems.
Inputs and outputs sent via the API are not used to train Anthropic's models.
A Business Associate Agreement is available for the first-party API, with zero-data-retention options for approved customers.
These terms are Anthropic's and can change. The authoritative, current detail lives with them: Anthropic Trust Center, Privacy Policy, and BAA and HIPAA details.
A quick summary of how your data is handled:
Your free wrap is created entirely in your browser. Nothing about it is sent to or stored on our servers.
We keep it so you can return to it and unlock Encore, but only as encrypted data we are unable to read. The key stays with you.
Because your wraps are encrypted for your eyes only, your passphrase or an enrolled device is what unlocks them. Keep your passphrase somewhere safe, as it is the way back in.
If you create a public link, it produces a separate, shareable copy that anyone with the link can view. Your private wrap stays encrypted, and turning the link off removes the shared copy.
Made for you. Readable only by you.
Your first wrap is free, and nothing about it ever becomes ours to read.
