Quickstart
VaultDrop never accepts plaintext. Your client encrypts the secret, sends the ciphertext, and builds the share link by appending the key as a URL fragment. The quickest route is the CLI; the API is fully documented in the API reference and as an OpenAPI 3.1 spec.
# 1. Encrypt locally (the CLI or an SDK does this for you), then create:
curl -sS https://vaultdrop.example/api/v1/secrets \
-H "Authorization: Bearer $VAULTDROP_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: deploy-4821" \
-d @sealed.json
# → { "id": "Xk2p…", "expiresAt": "…", "receiptToken": "…",
# "shareUrlBase": "https://vaultdrop.example/s/Xk2p…" }
# Share <shareUrlBase>#<linkKey> — the link key never leaves your machine.
# 2. Check status (does not consume)
curl -sS https://vaultdrop.example/api/v1/secrets/Xk2p…/status
# 3. Destroy before it is read
curl -sS -X POST https://vaultdrop.example/api/v1/secrets/Xk2p…/burn \
-H "Authorization: Bearer $VAULTDROP_API_KEY"Authentication
Anonymous requests can create secrets with anonymous limits. For higher limits, labels, listing and webhooks, create an API key in Dashboard → API keys (Pro plan and above) and send Authorization: Bearer vd_live_…. Keys are shown once and stored only as SHA-256 hashes. Scopes: secrets:create, secrets:read, secrets:burn, webhooks:manage. Test keys (vd_test_…) create secrets tagged environment: test and only list test secrets.
Envelope format (v1)
| linkKey | 32 random bytes, base64url — goes after # in the link |
| pwKey | Argon2id(NFKC(passphrase), salt16, t=2, m=19456 KiB, p=1, 32 bytes) — only with a passphrase |
| ikm | linkKey ‖ pwKey (or linkKey alone) |
| encKey / proof | HKDF-SHA256(ikm, salt="vaultdrop/v1/hkdf-salt", info="vaultdrop/v1/enc" | "vaultdrop/v1/auth", 32 bytes) |
| plaintext | uint32 big-endian length ‖ UTF-8 bytes ‖ zero padding to a multiple of 256 bytes |
| ct | AES-256-GCM(encKey, iv=12 random bytes, aad="vaultdrop/v1"), tag appended |
| verifier | base64url(SHA-256(proof)) — sent at creation |
To open a secret: GET /secrets/:id/status (returns KDF params), derive keys, POST /secrets/:id/redeem with { proof }, then decrypt the returned envelope. Redemption consumes a view only if the proof is correct.
CLI
# Install (Node 20.19+ or Bun), or run without installing via npx / bunx
npm install -g vaultdrop
export VAULTDROP_API_KEY=vd_live_… # optional; anonymous limits apply without it
# export VAULTDROP_SERVER=https://… # only for self-hosted servers
echo "$DB_PASSWORD" | vaultdrop --expires 1h
vaultdrop --passphrase --views 3 --label "staging creds" < creds.txt
vaultdrop open "https://vaultdrop.example/s/Xk2p…#M3f…"SDK examples
Each example encrypts locally and creates a secret over the REST API. TypeScript mirrors the CLI and is covered by interoperability tests; Python and Go are reference examples.
// Bun, Node 20+ or any modern browser. Mirrors cli/vaultdrop.mjs,
// which is covered by VaultDrop's interoperability tests.
import { argon2idAsync } from "@noble/hashes/argon2.js";
const subtle = crypto.subtle;
const te = new TextEncoder();
const b64u = (b: Uint8Array) => Buffer.from(b).toString("base64url");
const SALT = te.encode("vaultdrop/v1/hkdf-salt");
const AAD = te.encode("vaultdrop/v1");
const ARGON2 = { t: 2, m: 19456, p: 1 };
async function derive(ikm: Uint8Array) {
const base = await subtle.importKey("raw", ikm, "HKDF", false, [
"deriveKey",
"deriveBits",
]);
const hkdf = (info: string) => ({
name: "HKDF", hash: "SHA-256", salt: SALT, info: te.encode(info),
});
const encKey = await subtle.deriveKey(
hkdf("vaultdrop/v1/enc"), base,
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
);
const proof = new Uint8Array(
await subtle.deriveBits(hkdf("vaultdrop/v1/auth"), base, 256),
);
return { encKey, proof };
}
function pad(text: string) {
const body = te.encode(text);
const size = Math.max(Math.ceil((body.length + 4) / 256) * 256, 256);
const out = new Uint8Array(size);
new DataView(out.buffer).setUint32(0, body.length);
out.set(body, 4);
return out;
}
export async function createSecret(
server: string, apiKey: string, text: string, passphrase?: string,
) {
const linkKey = crypto.getRandomValues(new Uint8Array(32));
let ikm = linkKey;
let kdf = null;
if (passphrase) {
const salt = crypto.getRandomValues(new Uint8Array(16));
kdf = { alg: "argon2id", salt: b64u(salt), ...ARGON2 };
const pw = await argon2idAsync(
te.encode(passphrase.normalize("NFKC")), salt, { ...ARGON2, dkLen: 32 },
);
ikm = new Uint8Array([...linkKey, ...pw]);
}
const { encKey, proof } = await derive(ikm);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: AAD }, encKey, pad(text),
));
const verifier = b64u(new Uint8Array(await subtle.digest("SHA-256", proof)));
const res = await fetch(`${server}/api/v1/secrets`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
envelope: { v: 1, iv: b64u(iv), ct: b64u(ct) },
kdf,
verifier,
expiresIn: 3600,
}),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const { shareUrlBase } = await res.json();
return `${shareUrlBase}#${b64u(linkKey)}`;
}Errors & limits
Errors share one shape: { "error": { "code", "message", "details" }, "requestId" }. Include the requestId (also in the X-Request-Id header) when contacting support.
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing or invalid API key / session |
| 403 | forbidden, plan_restricted, invalid_proof | Scope missing, plan limit, or wrong passphrase/link (secret not consumed) |
| 404 | not_found | Unknown secret, or not yours |
| 410 | secret_unavailable | Consumed, expired or revoked — see details.state |
| 413 | payload_too_large | Ciphertext over your plan's size limit |
| 422 | validation_failed, idempotency_mismatch | Invalid body, or reused idempotency key |
| 429 | rate_limited, quota_exceeded | Slow down; honour Retry-After |
Default limits: anonymous creation 20 per 10 minutes per client; redemption 30 per minute per client and 10 per minute per secret; API keys per plan (e.g. Pro 120/min).
Idempotency
Send Idempotency-Key: <8–128 chars> with authenticated POST /secrets. Retrying with the same key and body within 24 hours returns the original response (header Idempotent-Replayed: true) instead of creating a duplicate. Reusing a key with a different body returns 422.
Webhooks
Events: secret.created, secret.viewed, secret.consumed, secret.revoked, secret.expired. Payloads contain metadata only. Each delivery is signed with your endpoint's secret; failed deliveries (non-2xx or timeout after 10s) are retried after 1m, 5m, 30m, 2h, 6h, 12h and 24h. Endpoints must be public HTTPS URLs.
{
"id": "evt_Qm9vYmFy…",
"type": "secret.consumed",
"createdAt": "2026-10-28T09:12:44.120Z",
"data": {
"secretId": "Xk2pR7…",
"label": "staging DB for Sam",
"state": "consumed",
"viewCount": 1,
"maxViews": 1,
"expiresAt": "2026-10-29T09:10:02.000Z",
"environment": "live"
}
}import { createHmac, timingSafeEqual } from "node:crypto";
// Header: "VaultDrop-Signature: t=1761650000,v1=5f1c…"
export function verifyVaultDropSignature(
secret: string,
header: string,
rawBody: string,
): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
// Reject stale timestamps (5-minute replay window).
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const given = String(parts.v1 ?? "");
return (
given.length === expected.length &&
timingSafeEqual(Buffer.from(given), Buffer.from(expected))
);
}
// Also de-duplicate on the VaultDrop-Event-Id header:
// deliveries are retried and may arrive more than once.CI/CD
Use a test key in staging pipelines and a live key in production. Never print the link to public logs.
name: Share generated credentials
on: workflow_dispatch
jobs:
share:
runs-on: ubuntu-latest
steps:
- name: Share generated DB password with on-call
env:
VAULTDROP_API_KEY: ${{ secrets.VAULTDROP_API_KEY }}
run: |
LINK=$(printf '%s' "$GENERATED_PASSWORD" \
| npx -y vaultdrop --expires 2h --label "rotation $GITHUB_RUN_ID")
echo "::add-mask::$LINK"
# Post $LINK to your incident channel; it opens exactly once.