API Documentation
Integrate end-to-end encrypted one-time secret sharing into your own apps and workflows
Your client does the encryption
Vanisec is zero-knowledge. The server never receives your plaintext, your password, or your encryption key. It stores ciphertext and nothing else. That means the API does not accept a secret directly. You encrypt first, then upload the result.
If you just want to share a secret, use the website. This API is for building your own client, and you have to implement the key derivation described below exactly, or your secret will not decrypt.
How the crypto works
Every secret is password-protected. Two independent values are derived from that password using PBKDF2-HMAC-SHA256, each with its own random 16-byte salt:
- Encryption key, derived with
encSalt, 256 bits, used as an AES-256-GCM key with a random 12-byte IV. This key never leaves your client. - Verifier, derived with
authSalt, 256 bits, sent to the server to prove you know the password. The server stores only its SHA-256 hash and compares in constant time, so it cannot recover the verifier, let alone the password or the key.
Because the two salts differ, holding the verifier tells you nothing about the encryption key. Wrong guesses are rejected without consuming the secret.
Parameters
KDF PBKDF2-HMAC-SHA256 Iterations 600000 (minimum accepted; sent as "iterations") Salts 16 random bytes each, for encSalt and authSalt Cipher AES-256-GCM IV 12 random bytes Encoding base64url, unpadded, for every binary field
Plaintext envelope
Encrypt this JSON structure, not your raw string. Text-only secrets set file to null.
{
"text": "your secret text",
"file": null
}
// or, to attach a file:
{
"text": "",
"file": {
"name": "id_rsa",
"type": "application/octet-stream",
"size": 2610,
"data": "<standard base64 of the file bytes>"
}
}Create a secret
Stores an encrypted payload and returns its id. Build the share link yourself as /secret/{id}.
Request body
{
"ciphertext": "string (required, base64url, max 12000000 chars)",
"iv": "string (required, base64url)",
"passwordProtected": true,
"encSalt": "string (required, base64url)",
"authSalt": "string (required, base64url)",
"verifier": "string (required, base64url)",
"iterations": 600000,
"expiresIn": 24
}passwordProtected must be true. Every secret requires a password. iterations must be at least 600000. expiresIn is in hours and must be one of 1, 6, 24, 72 or 168. The request body itself is capped at 16 MB.
Response
{
"id": "31c3e8d4-43a8-4e72-aac7-b38a00b46a50"
}Fetch retrieval parameters
Returns what you need to compute the verifier. This does not consume the secret, and it deliberately withholds encSalt until you have proven you know the password. A password-protected secret answers with 401, which is expected, not an error.
Response: 401
{
"requiresPassword": true,
"authSalt": "string (base64url)",
"iterations": 600000
}Always use the returned iterations rather than assuming the current default. Secrets created before the work factor was raised carry the older value.
Retrieve and burn a secret
Submits the verifier. On a match the secret is deleted atomically and returned in the same operation, so exactly one caller can ever succeed. On a mismatch nothing is deleted.
Request body
{
"verifier": "string (base64url)"
}Response: 200
{
"ciphertext": "string (base64url)",
"iv": "string (base64url)",
"encSalt": "string (base64url)",
"iterations": 600000
}Derive the AES key from your password and this encSalt, decrypt, then parse the envelope.
Response: 401
{
"error": "Invalid password",
"attemptsRemaining": 7
}Create a pairing code
Issues a short code that resolves to an existing secret id, for moving a secret onto a device where a full link cannot reasonably be typed. The code carries the id and nothing else: the password still gates retrieval, so a code grants strictly less than the link does.
Request body
{
"id": "31c3e8d4-43a8-4e72-aac7-b38a00b46a50"
}Response
{
"code": "4F2K-9QX1",
"expiresIn": 300
}Codes live five minutes, or until the secret expires if that comes first, and are usable once. The short life is what stands in for the entropy a code gives up against a full id, so it is not configurable. Returns 404 when the secret is missing or has expired.
Redeem a pairing code
Exchanges a code for the secret id, then continues through the normal retrieval endpoints above. POST rather than GET so a live code never reaches an access log or a Referer header.
Request body
{
"code": "4f2k-9qx1"
}Case, dashes and spaces are all ignored. Because the alphabet omits I, L, O and U, a typed I or L is read as 1 and O as 0.
Response: 200
{
"id": "31c3e8d4-43a8-4e72-aac7-b38a00b46a50"
}Response: 404
{
"error": "That code has expired or has already been used"
}Unknown, malformed, expired and already-redeemed codes all answer identically, so a wrong guess reveals nothing. Redemption is rate limited per address.
Clipboard
The shortest path: store something, hand someone a four digit code, they read it once. Clips expire after five minutes.
Unlike the endpoints above, this one is not zero-knowledge. Four digits is ten thousand possibilities, far too few to derive a key from, so the key travels with the ciphertext and is stored beside it. Vanisec can decrypt a clip while it exists, and the code space is small enough to enumerate. The five minute lifetime and the single use are what bound that, which is why neither is configurable.
Store
// request
{
"ciphertext": "string (base64url, max 12000000 chars)",
"iv": "string (base64url)",
"key": "string (base64url, raw AES-GCM 256 key)"
}
// response
{ "code": "4242", "expiresIn": 300 }The server picks the code, so a client cannot claim one or probe for which are free. If every code it tries is taken it answers 503 rather than evicting a live clip.
Open
// request
{ "code": "4242" }
// response
{ "ciphertext": "...", "iv": "...", "key": "..." }Atomic fetch-and-delete, so a clip opens exactly once. Unknown, expired and already-opened all return the same 404. Spaces and dashes in the code are ignored. Opening is rate limited per address, well below what walking the code space would need.
Complete example
Node.js 18 or newer, no dependencies. It uses the built-in Web Crypto API. The same code runs in a browser unchanged apart from the base64 helpers.
const BASE = 'https://vanisec.clouddrove.com'
const ITERATIONS = 600000
const b64url = (bytes) =>
Buffer.from(bytes).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const unb64url = (s) =>
new Uint8Array(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'))
async function deriveBits(password, salt, iterations) {
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits'])
return new Uint8Array(await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, key, 256))
}
async function createSecret(text, password) {
const encSalt = crypto.getRandomValues(new Uint8Array(16))
const authSalt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12))
const envelope = JSON.stringify({ text, file: null })
const keyBits = await deriveBits(password, encSalt, ITERATIONS)
const key = await crypto.subtle.importKey(
'raw', keyBits, { name: 'AES-GCM' }, false, ['encrypt'])
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(envelope))
const verifier = await deriveBits(password, authSalt, ITERATIONS)
const res = await fetch(BASE + '/api/secrets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ciphertext: b64url(new Uint8Array(ciphertext)),
iv: b64url(iv),
passwordProtected: true,
encSalt: b64url(encSalt),
authSalt: b64url(authSalt),
verifier: b64url(verifier),
iterations: ITERATIONS,
expiresIn: 24,
}),
})
if (!res.ok) throw new Error('create failed: ' + res.status)
const { id } = await res.json()
return BASE + '/secret/' + id
}
async function readSecret(url, password) {
const id = url.split('/').pop()
const metaRes = await fetch(BASE + '/api/secrets/' + id)
const meta = await metaRes.json()
if (metaRes.status !== 401) throw new Error('not available: ' + metaRes.status)
const verifier = await deriveBits(password, unb64url(meta.authSalt), meta.iterations)
const res = await fetch(BASE + '/api/secrets/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verifier: b64url(verifier) }),
})
if (!res.ok) throw new Error('read failed: ' + res.status)
const data = await res.json()
const keyBits = await deriveBits(password, unb64url(data.encSalt), data.iterations)
const key = await crypto.subtle.importKey(
'raw', keyBits, { name: 'AES-GCM' }, false, ['decrypt'])
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: unb64url(data.iv) }, key, unb64url(data.ciphertext))
return JSON.parse(new TextDecoder().decode(plaintext)).text
}
const url = await createSecret('deploy key: abc123', 'hunter2')
console.log('created:', url)
console.log('read back:', await readSecret(url, 'hunter2'))
// a second read fails, the secret is goneErrors
| Status | Meaning |
|---|---|
| 400 | Malformed body, missing field, iterations below the minimum, or an expiry outside the allowed set |
| 401 | On GET, the secret exists and needs a password. On POST, the verifier did not match |
| 404 | No such secret, or it has already been viewed |
| 410 | The secret expired, or was taken between your two requests |
| 413 | Request body over 16 MB |
| 429 | Rate limited. Check the Retry-After header |
Rate limits
All limits use fixed windows and return 429 with a Retry-After header when exceeded.
- Creating secrets: 30 per 10 minutes per IP
- Reading metadata: 120 per 15 minutes per IP
- Password attempts: 60 per 15 minutes per IP, and 10 per 15 minutes per secret
The per-secret limit is what stops a leaked link from being brute-forced. A failed attempt never deletes the secret, so the limit is the only thing bounding guesses.
Self-Hosting
Vanisec is fully open source. Run your own instance on your own infrastructure. Docker and docker-compose configs are included.
View on GitHub