If you discover a security vulnerability in Secret, please report it privately via GitHub Security Advisories.
Do not open a public issue for security vulnerabilities.
We will acknowledge your report within 48 hours and aim to release a fix within 7 days for critical issues.
The following are in scope:
- Authentication bypass (PoW, API keys)
- Encryption/decryption flaws (key leakage, nonce reuse, plaintext exposure)
- Server-side access to plaintext content (zero-knowledge violation)
- SQL injection, path traversal, SSRF
- XSS, CSRF, header injection
- Rate limit bypass
- Information disclosure via error messages or timing side-channels
Secret uses a double-encryption model:
- Client-side: XChaCha20-Poly1305 (libsodium) with keys derived via Argon2id
- Server-side: AES-256-GCM (Node.js crypto) at rest, as an extra layer — see what it covers
The decryption key is stored in the URL fragment (#key), which browsers never send to the server. The server never has access to plaintext content.
Zero-knowledge covers the content, not the metadata. This is what the operator, or anyone holding the server's database and storage, can and cannot learn:
| Data | Visible to the server |
|---|---|
| Text, file contents, file names and types | No — encrypted in the browser before upload |
| Decryption key and password | No — the key stays in the URL fragment, the password never leaves the browser |
| Size | Yes — ciphertext is not padded, so it gives the size of the content to within a few bytes. Chunked uploads also reveal each file's size, since every file starts on a new chunk |
| Number of files, password set or not, read limit and reads so far, expiry, creation time | Yes — stored in clear to enforce them |
| When a note is created, read and deleted | Yes |
| IP addresses | Seen for rate limiting and kept in memory for about a minute; never written to the app's logs. Your reverse proxy may log them |
| Note IDs | Appear in cleanup and deletion-retry logs, never in request logs |
The at-rest layer uses SERVER_ENCRYPTION_KEY. Left empty, that key is generated
on first start and stored in the data volume (.encryption_key), so a copy of the
volume, or of a backup of it, holds both the key and the data: the layer then only
protects a leaked database or S3 bucket on its own. To cover a stolen volume too,
set SERVER_ENCRYPTION_KEY from a secret store and keep it out of the volume and
its backups. Either way, the content stays protected by the end-to-end layer.
- Timing-safe token comparison (
crypto.timingSafeEqual) - Key buffers zeroed after use (
sodium.memzero) — a best effort: JavaScript strings and libsodium's internal WASM copies are not wiped - Strict Content Security Policy (CSP)
- Per-IP rate limiting on all endpoints
- Zod validation on all inputs with max length constraints
Content-Disposition: attachment+X-Content-Type-Options: nosniffon file downloads- SQLite
secure_deleteand WAL mode - Non-root Docker container with read-only filesystem
- No logging of note content, decryption keys, or IP addresses (note IDs do appear in cleanup and deletion-retry logs)
What Secret protects against
- A curious or compromised server operator. The server stores only doubly
encrypted blobs. The client-side key lives in the URL fragment (
#key) and is never transmitted, so a full database/disk compromise does not reveal plaintext. - A network observer. With HTTPS terminated at the reverse proxy (see Assumptions), traffic is encrypted in transit; the fragment is never sent on the wire at all.
- Automated write abuse. Browser writes require a proof-of-work token (Cap); SDK writes require an API key. Reads are intentionally open.
- Tampering / forgery. Both encryption layers are authenticated (AEAD), so any modification of stored ciphertext is detected on decryption and surfaced as a single, cause-hiding error (no decryption oracle).
Out of scope / explicit non-goals
- Anyone who has the full share link (including the
#key) can read the note — this is by design. Protect the link; optionally add a password. - A compromised client (malicious browser extension, XSS in an embedding page, a backdoored build of the web app) can read plaintext, since decryption happens in the browser. Integrity of the delivered JavaScript is assumed (see Assumptions).
- Endpoint security of sender/recipient (keyloggers, shoulder-surfing, clipboard scrapers) is the user's responsibility.
- Traffic-analysis / metadata correlation at the network layer.
- TLS is terminated at the reverse proxy. The app server speaks plain HTTP behind a proxy that enforces HTTPS; HSTS is advertised by the app. Deploying the app server directly on the public internet without TLS breaks the in-transit guarantees.
- The server never sees plaintext. This holds only as long as the double-encryption layers and the URL-fragment key model are kept intact (removing either is a security-model change).
- The delivered web app and its dependencies are trusted. Zero-knowledge relies on the integrity of the JavaScript served to the browser and the build/supply chain.
SERVER_ENCRYPTION_KEYis kept secret and is backed up out-of-band.
- Metadata enumeration via
GET /api/v1/notes/:id/exists. This endpoint reveals whether a note ID exists and exposes coarse metadata (hasPassword,fileCount,chunked, expiry, and the configured read limit — not the remaining count) without authentication. It is rate-limited but is not zero-knowledge with respect to existence/metadata. Note IDs are long and random, so enumeration is impractical, but the leak is acknowledged. - One instance only. SQLite, the proof-of-work challenges and the rate-limit
counters all live in a single process, and none of them can be shared. Run exactly
one replica (on Kubernetes:
replicas: 1, theRecreatestrategy and aReadWriteOncevolume). A second replica would reject the proof-of-work tokens issued by the first and split every rate limit in two. SERVER_ENCRYPTION_KEYis not rotatable. It is mixed into every stored note; changing it renders all existing notes permanently undecryptable. There is no re-encryption/rotation path today.- Proof-of-work is best-effort. Cap raises the cost of automated writes but does
not stop a determined attacker willing to spend CPU. Tune
CAP_DIFFICULTY/CAP_CHALLENGE_COUNTupward under sustained abuse. - No forward secrecy across a leaked link. Because the key is the link, anyone who ever captured the link can decrypt the note until it expires or is deleted.
Fixes ship in new 1.x releases; older releases are not patched. Run :1: it
follows every 1.x release and picks up base-image (OS and Node) fixes between
releases. An exact tag such as :1.3.2 never changes, so it gets neither. See
Pinning a version for every tag, and
CHANGELOG.md for what each release changes.
The SDK (@largerio/secret-sdk) is supported in its latest 1.x version.