A developer pastes an API key into a conventional pastebin. The service receives the plaintext, writes it to a database, and returns a link. Anyone who can read that database can read the key: an administrator, an attacker with stolen credentials, or someone restoring a backup.
HTTPS protects the connection against passive network eavesdroppers. It does not prevent the service operator from reading the request after TLS termination. A pastebin that accepts plaintext has that access by design.
I built CloakBin around browser-side encryption. Senders encrypt before uploading. Recipients decrypt after downloading. The backend stores an encrypted content blob and operational metadata, without the content key or password.
The source repository uses SvelteKit and TypeScript. You can inspect the cryptographic boundary in src/lib/crypto.ts and the implementation details in docs/SECURITY-MODEL.md.
Separate the identifier from the secret
For a paste without password protection, the sender shares a link shaped like this:
https://cloakbin.com/p/abc#ENCRYPTION_KEY
| |
| +-- browser-side decryption key
+----- server-side paste identifier
Normal HTTP navigation:
GET /p/abc HTTP/1.1
Host: cloakbin.com
No #ENCRYPTION_KEY in the request target.
The recipient's browser requests /p/abc. Client-side JavaScript reads location.hash, imports the key, fetches the encrypted paste, and decrypts it.
RFC 3986, section 3.5 defines the fragment as a component that the client separates from the URI before dereferencing it. During normal HTTP navigation, browsers do not include the fragment in the request target. Browsers also omit fragments from the normal Referer header.
That rule does not stop application JavaScript from sending a fragment in a JSON body, an analytics event, or a custom header. Developers must keep the key out of those code paths. Putting a key after # gives the application a useful boundary, not permission to log location.href.
A query parameter would cross that boundary:
/p/abc?key=SECRET -> server receives the key
/p/abc#SECRET -> browser retains the fragment during navigation
Treat the full share link as a credential. Anyone who receives both the identifier and fragment key can decrypt the paste.
Follow the bytes from sender to recipient
sequenceDiagram
participant A as Browser A (sender)
participant S as CloakBin server
participant D as Database
participant B as Browser B (recipient)
A->>A: Generate AES-256 key and fresh 96-bit IV
A->>A: Compress plaintext and encrypt with AES-GCM
A->>S: POST ciphertext frame and metadata
S->>D: Store encrypted content
S-->>A: Return paste ID
A->>A: Build share URL with local fragment key
A-->>B: Share /p/abc#key through chosen channel
B->>S: Request /p/abc without fragment
B->>B: Extract key from location.hash
B->>S: Fetch encrypted paste by ID
S->>D: Read encrypted content
D-->>S: Ciphertext frame
S-->>B: Ciphertext frame and metadata
B->>B: Import key, authenticate, decrypt, decompress
The server needs the identifier to retrieve the paste. It does not need the key to perform that lookup.
Operators still observe metadata: content length, creation time, expiration, language selection, and access patterns. At the HTTP layer they can also observe IP addresses. In this article, “zero-knowledge” describes content confidentiality against a backend that follows the protocol. It does not claim anonymity or a formal zero-knowledge proof.
Encrypt with Web Crypto
CloakBin uses the browser's Web Crypto API, available through window.crypto.subtle. The implementation uses the shorter global crypto.subtle spelling.
For random-key pastes, the browser generates a 256-bit AES key and a fresh 12-byte initialization vector for each encryption. AES-GCM authenticates the encrypted bytes as well as encrypting them. With the 128-bit tag used here, Web Crypto appends 16 bytes of authentication data to its encryption output.
Never reuse an IV with the same AES-GCM key. Reuse can expose relationships between messages and undermine authentication. Use crypto.getRandomValues, not Math.random, for the 96-bit IV.
The following teaching example shows the AES-GCM core without CloakBin's compression or version framing. It runs in a browser secure context, such as HTTPS or localhost. It is not a replacement for the repository's serialized format.
type EncryptedPaste = {
iv: Uint8Array;
ciphertext: ArrayBuffer;
};
export async function generatePasteKey(): Promise<CryptoKey> {
return window.crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // Export the key for the share-link fragment.
['encrypt', 'decrypt']
);
}
export async function encryptPaste(
plaintext: string,
key: CryptoKey
): Promise<EncryptedPaste> {
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const bytes = new TextEncoder().encode(plaintext);
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, tagLength: 128 },
key,
bytes
);
return { iv, ciphertext };
}
export async function decryptPaste(
encrypted: EncryptedPaste,
key: CryptoKey
): Promise<string> {
const plaintext = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: new Uint8Array(encrypted.iv),
tagLength: 128
},
key,
encrypted.ciphertext
);
return new TextDecoder('utf-8', { fatal: true }).decode(plaintext);
}
If someone changes the ciphertext or supplies the wrong key, subtle.decrypt rejects authentication. The caller should display a decryption error, without treating the bytes as plaintext or attempting to display unauthenticated content.
The IV does not need secrecy. The sender can store it alongside the ciphertext. The AES key does need secrecy.
Export only to the fragment
A raw AES-256 key contains 32 bytes. CloakBin exports those bytes and encodes them as base64url, replacing + and / with URL-safe characters and dropping padding.
export async function keyToFragment(key: CryptoKey): Promise<string> {
const raw = new Uint8Array(
await window.crypto.subtle.exportKey('raw', key)
);
return btoa(String.fromCharCode(...raw))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
export async function keyFromFragment(fragment: string): Promise<CryptoKey> {
const base64 = fragment.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
const raw = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
if (raw.byteLength !== 32) throw new Error('Invalid AES-256 key length');
return window.crypto.subtle.importKey(
'raw', raw, { name: 'AES-GCM' }, false, ['decrypt']
);
}
// After the upload returns an ID, run this in the sender's browser:
// const url = new URL(`/p/${id}`, window.location.origin);
// url.hash = await keyToFragment(key);
// Share url.href, without posting it back to the API.
Keep window and location.hash access in browser event handlers or Svelte's onMount. SvelteKit renders components on the server too; server rendering cannot read the recipient's fragment. The SvelteKit FAQ covers this browser-only execution boundary.
Serialize an encrypted frame, not plaintext JSON
CloakBin's current encoder compresses UTF-8 text with gzip before encryption. It then writes a versioned binary frame and base64-encodes that frame for storage:
Current v1 frame, before base64 encoding
Offset Length Field
0 2 bytes "CB" magic: 0x43 0x42
2 1 byte Version: 0x01
3 12 bytes Random AES-GCM IV
15 remainder Encrypted gzip bytes + GCM authentication tag
The recipient decodes base64, reads the version and IV, authenticates and decrypts the encrypted bytes, then decompresses the result. CloakBin also accepts its older uncompressed format so existing pastes remain readable.
Base64 provides a transport encoding. Anyone can decode it to recover the frame, including its public IV. They still need the AES key to recover the compressed plaintext.
Compression reduces upload size for repetitive text, but an observer can still learn the encrypted length. Do not interpret compression or encryption as padding that hides message size.
Password protection: distinguish derivation from wrapping
CloakBin's current password mode derives the content-encryption key from the password. It does not derive a secondary wrapping key around a random content key.
That distinction matters when reviewing an implementation. In a wrapping design, developers would generate a random content key, derive a password-based key, and encrypt or wrap the content key with it. CloakBin instead uses the password-derived AES key to encrypt the paste itself.
The browser performs these steps:
- Generate a random 16-byte salt.
- Import the password as PBKDF2 key material.
- Derive a 256-bit AES-GCM key with PBKDF2-HMAC-SHA-256 and 100,000 iterations.
- Encrypt the paste with that key and a fresh 96-bit IV.
- Upload the ciphertext and salt, without the password.
The recipient receives a link without a fragment key, enters the password, and repeats the derivation using the stored salt.
const material = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password),
'PBKDF2', false, ['deriveKey']
);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 100_000 },
material,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
An attacker with the salt and ciphertext can attempt offline password guesses. They can derive a candidate key and use GCM authentication to check it. The iteration count adds work per guess; it cannot give a short password the entropy of a random 256-bit key. Choose a long, unpredictable password and share it through a separate trusted channel when that separation matters.
Burn-after-read needs an atomic database operation
A developer who implements burn as a read followed by a delete creates a race:
Reader A: read document ---------- delete
Reader B: read document ---------- delete
Result: both readers can obtain the same content.
CloakBin's MongoDB adapter uses a conditional findOneAndDelete through its model:
Model.findOneAndDelete({
_id: id,
burnAfterRead: true,
expiresAt: { $gt: new Date() }
});
MongoDB performs this as one atomic find-and-delete operation, using the find-and-modify family of semantics. Developers do not need a separate read and a later delete for this single-document operation. Among concurrent matching burn operations, at most one caller receives the deleted document.
The UI asks the recipient to confirm before calling the burn endpoint. A normal page GET does not consume the paste. “First read” therefore means the first successful confirmed consumption, not the first page navigation or link preview. Review src/lib/db/adapters/mongodb.ts and the burn endpoint when checking that boundary.
Atomic consumption cannot guarantee that the recipient sees the plaintext. If the server deletes the document and the connection fails before delivery, the recipient may lose the paste. Nor can deletion erase a copy that a recipient saved, an operator retained, or a backup contains. Check the adapter you deploy: the repository's security model notes that some adapters lack a completed atomic burn implementation.
Verify the boundary in Chrome DevTools
Use a disposable marker such as cloakbin-network-check-12345, not a production API key. These steps describe a reproducible inspection, not a claim that I ran a live audit in this article.
- Open Chrome DevTools, select Network, and enable Preserve log.
- Create a paste without password protection. Select the outgoing
POST /api/pasterequest and inspect Payload. - Check that
contentcontains the base64 encrypted frame rather than your marker. Inspect the other fields: expiration, burn preference, and language metadata can remain readable. “Ciphertext-only content” does not mean that the entire JSON request contains no metadata. - Compare the generated address with the request URL. The share link includes
#key; the upload should contain neither the key nor the full share link. - Open the share link in another tab with DevTools recording. Inspect the document request and paste API requests. Their request URLs should omit the fragment. Inspect headers and bodies too, checking for application code that might copy the key into a request.
- Inspect the paste response. You should receive encrypted
content, then see the browser display the decrypted marker after local processing. - Repeat with password protection. Expect a salt, no uploaded password, and a share link without a fragment key.
Do not publish a HAR file from a real secret-sharing session without reviewing it. Even if normal HTTP requests omit the fragment, debugging tools, screenshots, and copied browser addresses can expose sensitive material.
A Network inspection gives evidence about that interaction. It cannot prove that a future client build, a browser extension, or an injected script will preserve the same boundary.
The browser remains part of the trust model
A database dump gives an attacker ciphertext and metadata. With random-key pastes, it does not give them the fragment key. A passive network observer faces HTTPS plus application-layer encryption for paste contents.
An attacker who controls the JavaScript delivered to the sender or recipient has a different opportunity. They can change the client to upload plaintext, read location.hash, or capture a password before derivation. XSS and malicious browser extensions create similar risks. Browser-side encryption does not protect you from malicious code running inside the browser that handles the secret.
Deploy with HTTPS, review client dependencies, restrict script execution, and avoid analytics or logging that captures full URLs. Keep the share link out of issue trackers and support transcripts. Use short expiration when a paste has a short useful lifetime, and rotate credentials after sharing when your workflow permits it.
You can inspect CloakBin on GitHub or try cloakbin.com. Start your review at the browser crypto functions, follow the POST body into storage, and check the recipient's fragment handling. Those three places determine whether the server ever receives the secret it claims not to know.

Top comments (0)