Updated

Zero-Knowledge Encryption: How Vaulted Keeps Your Secrets Private

By

Zero-knowledge encryption means the server never sees your plaintext data or encryption keys. Vaulted implements this by encrypting every secret in your browser using AES-256-GCM before any data reaches the server. The server stores only ciphertext — it never has access to the decryption key, which lives exclusively in the URL fragment.

This post walks through the exact cryptographic implementation: how keys are generated, how data is encrypted, how the key reaches the recipient, and what happens when you add a passphrase. Every detail here reflects the actual code running in your browser.

For the high-level overview of why client-side encryption matters, see our earlier post on client-side encryption. This post goes deeper into the cryptographic primitives.

Key generation

Every time you create a secret, Vaulted generates a fresh AES-256-GCM encryption key using the Web Crypto API:

crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
  'encrypt',
  'decrypt',
]);

A few things to note:

  • AES-GCM (Galois/Counter Mode) provides both confidentiality and integrity — it detects tampering, not just unauthorized reading.
  • 256-bit key length is the strongest option available for AES and is used by governments and financial institutions worldwide.
  • Extractable: true — the key can be exported as raw bytes so it can be placed in the URL fragment for the recipient.

The Web Crypto API delegates to the browser's native cryptographic engine (e.g., BoringSSL in Chromium, NSS in Firefox). The key is generated securely in memory and never touches disk or network.

Encryption

With the key generated, Vaulted encrypts your plaintext:

const iv = crypto.getRandomValues(new Uint8Array(12));

crypto.subtle.encrypt(
  { name: 'AES-GCM', iv },
  key,
  new TextEncoder().encode(plaintext),
);

The initialization vector (IV): AES-GCM requires a 12-byte (96-bit) random IV for each encryption operation. The IV does not need to be secret — it is stored alongside the ciphertext — but it must never be reused with the same key. Since Vaulted generates a new key for every secret, IV reuse is impossible by design.

Encoding: Both the ciphertext and IV are encoded as base64url strings (standard base64 with + replaced by -, / replaced by _, and trailing = stripped). This makes them safe to include in URLs and JSON payloads.

The encrypted ciphertext and IV are sent to the server for storage. The plaintext never leaves the browser.

URL fragment key delivery

After encryption, the key is exported and placed in the URL fragment — the part after #:

const rawKey = await crypto.subtle.exportKey('raw', key);
// rawKey → base64url-encoded string

The resulting share link looks like:

https://vaulted.fyi/s/abc123#dGhpcyBpcyBhIGtleQ
                              ^^^^^^^^^^^^^^^^^^^^^^^^
                              encryption key (base64url)

The # separator is critical. Per RFC 3986, the fragment identifier is processed entirely by the client. Browsers never include the fragment in HTTP requests — not in the URL, not in the Referer header, not anywhere the server can see it.

When the recipient opens the link:

  1. The browser requests /s/abc123 from the server (no fragment)
  2. The server returns the encrypted ciphertext and IV
  3. JavaScript reads the fragment from window.location.hash
  4. The key is imported back into the Web Crypto API
  5. The ciphertext is decrypted locally

The server facilitates storage and retrieval of the encrypted blob. It never participates in the cryptographic operations.

Optional passphrase protection

For secrets that need additional protection, Vaulted supports passphrase-based key wrapping. This adds a second layer: even if someone intercepts the link, they cannot decrypt the secret without the passphrase.

Key derivation with PBKDF2

When you set a passphrase, Vaulted derives a wrapping key using PBKDF2:

crypto.subtle.deriveKey(
  {
    name: 'PBKDF2',
    salt, // 16 bytes, randomly generated
    iterations: 100_000,
    hash: 'SHA-256',
  },
  keyMaterial, // the passphrase, imported as raw key material
  { name: 'AES-KW', length: 256 },
  false,
  ['wrapKey', 'unwrapKey'],
);
  • 100,000 iterations make brute-force attacks computationally expensive.
  • 16-byte random salt ensures identical passphrases produce different derived keys across different secrets.
  • SHA-256 is the hash function used in each PBKDF2 iteration.
  • The derived key is an AES-KW (AES Key Wrap, RFC 3394) key, not an AES-GCM key. Key wrapping is a distinct operation from encryption.

Wrapping the encryption key

The derived AES-KW key wraps the original encryption key:

crypto.subtle.wrapKey('raw', encryptionKey, wrappingKey, 'AES-KW');

The URL fragment now contains both the wrapped key and the salt, separated by a dot:

#wrappedKeyBase64url.saltBase64url

Unwrapping on decryption

When the recipient enters the passphrase, Vaulted reverses the process:

  1. Split the fragment on . to get the wrapped key and salt
  2. Derive the same AES-KW wrapping key using PBKDF2 with the passphrase and salt
  3. Unwrap the encryption key with crypto.subtle.unwrapKey
  4. Decrypt the ciphertext with the unwrapped AES-GCM key

If the passphrase is wrong, unwrapKey throws — there is no partial decryption or information leakage.

Decryption flow

Without a passphrase, decryption is straightforward:

  1. Import the key from the URL fragment (crypto.subtle.importKey)
  2. Decrypt the ciphertext with the IV (crypto.subtle.decrypt)
  3. Decode the plaintext from UTF-8 bytes

With a passphrase:

  1. Split the fragment to extract the wrapped key and salt
  2. Derive the wrapping key from the passphrase and salt (PBKDF2)
  3. Unwrap the encryption key (AES-KW)
  4. Decrypt the ciphertext (AES-GCM)
  5. Decode the plaintext

All of this happens in the browser. The server returns the encrypted blob and metadata (view count, expiry) — nothing more.

Threat model

No security tool protects against everything. Here is what Vaulted's architecture is designed to handle, and what falls outside its scope.

What Vaulted protects against

  • Server compromise — The server stores only encrypted ciphertext. Without the key (which is never sent to the server), the data is unreadable. A full database dump yields only encrypted blobs.
  • Database breach — Same as above. Ciphertext without keys is computationally useless with AES-256-GCM.
  • Man-in-the-middle on the server side — The encryption key lives in the URL fragment, which is never transmitted in HTTP requests. TLS protects the ciphertext in transit.
  • Service operators reading secrets — Zero-knowledge means exactly that. We cannot read your secrets even if compelled to do so, because we do not have the keys.

What Vaulted does NOT protect against

  • Compromised browser or device — If the recipient's device has malware or a keylogger, the decrypted plaintext can be captured after decryption.
  • Malicious browser extensions — Extensions with page access can read the DOM after decryption, including the revealed secret.
  • Link interception — The URL contains the encryption key in the fragment. Anyone who obtains the full URL (including the fragment) can decrypt the secret. Share links through secure channels.
  • Shoulder surfing — Once the secret is displayed on screen, anyone watching can see it.
  • Recipient behavior — After decryption, the recipient can screenshot, copy, or share the plaintext. Vaulted cannot prevent this.

The passphrase feature mitigates link interception: even with the full URL, the attacker still needs the passphrase. Share the passphrase through a different channel (a phone call, a separate message) for defense in depth.

Summary

Vaulted's zero-knowledge encryption means:

  1. A fresh AES-256-GCM key is generated for every secret
  2. Encryption happens entirely in your browser
  3. The server stores only encrypted ciphertext
  4. The decryption key travels only in the URL fragment, which browsers never send to servers
  5. Optional passphrase wrapping adds a second layer via PBKDF2 and AES-KW

The result: even if every server, database, and network path were compromised simultaneously, your secrets remain encrypted. The keys exist only in the links you share.


Ready to share a secret securely? Create a secret on Vaulted — it takes 10 seconds.


Related