The Evolution Continues (Again)
Last time, I shared how MdBin migrated to Streamdown for better markdown rendering—Mermaid diagrams, KaTeX math, built-in controls, the works.
But there was one feature request that kept coming up: "Can I share sensitive content without you seeing it?"
Today, I'm excited to announce: end-to-end encrypted pastes are live.
The Problem with Traditional Pastebins
Here's the uncomfortable truth about every pastebin service: they can read your content.
When you paste something into Pastebin, GitHub Gists, or even MdBin (until today), the server receives your plaintext, stores it, and serves it back. The service operator—and anyone who gains access to their database—can read everything you've shared.
For most use cases, this is fine. But what about:
- API keys you need to share with a teammate
- Private configuration snippets
- Sensitive meeting notes
- Personal information
The traditional answer is "just use Signal" or "encrypt it yourself first." But that adds friction, and friction kills adoption.
The Solution: True End-to-End Encryption
With MdBin's new encrypted paste feature, the server becomes a dumb blob storage. Here's what happens:
- You type content in the browser
- JavaScript encrypts it with your password before it leaves your device
- We store the encrypted blob—we literally cannot read it
- Recipients decrypt in their browser using the same password
The server never sees your plaintext. We don't store your password. We couldn't decrypt your content even if we wanted to.
The Crypto Implementation
I didn't roll my own crypto (please never do this). Instead, I used the Web Crypto API with industry-standard algorithms.
Key Derivation: PBKDF2
Passwords are weak. Turning a password into a strong encryption key requires a key derivation function:
const PBKDF2_ITERATIONS = 310000 // OWASP 2023 recommendation
async function deriveKey(
password: string,
salt: Uint8Array
): Promise<CryptoKey> {
const encoder = new TextEncoder()
const passwordBuffer = encoder.encode(password)
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordBuffer,
'PBKDF2',
false,
['deriveBits', 'deriveKey']
)
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
Why 310,000 iterations? That's the OWASP 2023 recommendation for PBKDF2-HMAC-SHA256. It makes brute-force attacks computationally expensive while still being fast enough on modern devices.
Encryption: AES-256-GCM
For the actual encryption, I chose AES-256-GCM—authenticated encryption that provides both confidentiality and integrity:
export async function encrypt(
plaintext: string,
password: string
): Promise<string> {
const encoder = new TextEncoder()
const plaintextBuffer = encoder.encode(plaintext)
// Generate random salt and IV for each encryption
const salt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12))
const key = await deriveKey(password, salt)
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
plaintextBuffer
)
// Combine: salt || iv || ciphertext
const combined = new Uint8Array(
16 + 12 + ciphertext.byteLength
)
combined.set(salt, 0)
combined.set(iv, 16)
combined.set(new Uint8Array(ciphertext), 28)
return btoa(String.fromCharCode(...combined))
}
Key points:
- Random salt per paste: Same password + different content = different ciphertext
- Random IV per encryption: Required by GCM mode for security
- Base64 output: Safe to store in any database text field
Decryption
Decryption reverses the process:
export async function decrypt(
encrypted: string,
password: string
): Promise<string> {
const combined = new Uint8Array(
atob(encrypted).split('').map(c => c.charCodeAt(0))
)
// Extract salt, iv, and ciphertext
const salt = combined.slice(0, 16)
const iv = combined.slice(16, 28)
const ciphertext = combined.slice(28)
const key = await deriveKey(password, salt)
const plaintextBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
)
return new TextDecoder().decode(plaintextBuffer)
}
If you provide the wrong password, crypto.subtle.decrypt throws—GCM's authentication tag verification fails. No partial decryption, no garbage output, just a clean error.
The UX Implementation
Crypto is useless if people don't use it. Here's how I made encryption approachable.
Normal/Encrypted Toggle
The paste form now has a mode switcher:
<div className="flex items-center gap-2 p-1 bg-gray-100 rounded-lg w-fit">
<button
onClick={() => setIsEncrypted(false)}
className={!isEncrypted ? 'bg-white shadow-sm' : ''}
>
<LockOpen className="w-4 h-4" />
Normal
</button>
<button
onClick={() => setIsEncrypted(true)}
className={isEncrypted ? 'bg-white shadow-sm' : ''}
>
<Lock className="w-4 h-4" />
Encrypted
</button>
</div>
Simple, obvious, no hidden settings pages.
Password Strength Meter
Weak passwords defeat encryption. I added real-time password validation:
export function validatePassword(password: string): ValidationResult {
const checks = {
minLength: password.length >= 8,
hasLowercase: /[a-z]/.test(password),
hasUppercase: /[A-Z]/.test(password),
hasNumber: /[0-9]/.test(password),
hasSpecial: /[!@#$%^&*...]/.test(password),
}
let score = Object.values(checks).filter(Boolean).length
if (password.length >= 12) score++
if (password.length >= 16) score++
// Map to 0-4 strength scale
return { isValid: checks.minLength, checks, strength: score }
}
The UI shows a color-coded bar and checkmarks for each requirement. Users see exactly what makes a strong password.
The Sharing Trick: URL Hash
Here's a clever feature: you can share the password in the URL.
https://mdbin.sivaramp.com/e/abc123#MySecretPassword
The fragment after # never gets sent to the server—it's browser-only. So you can share a complete self-decrypting link, and we still never see the password.
useEffect(() => {
if (typeof window !== 'undefined') {
const hash = window.location.hash.slice(1)
if (hash) {
const password = decodeURIComponent(hash)
// Clear hash immediately to prevent browser history leak
window.history.replaceState(null, '', window.location.pathname)
handleDecrypt(password, false)
}
}
}, [])
The hash is immediately cleared from the URL bar after reading. It won't appear in browser history, bookmarks, or shared screenshots.
Security Considerations
What We Can't Do
With encrypted pastes, MdBin cannot:
- Read your content
- Reset or recover your password
- Comply with data requests for your plaintext (we don't have it)
- Tell you what you encrypted if you forget the password
This is a feature, not a bug.
localStorage Trade-offs
The "Remember password" feature stores passwords in localStorage. I added clear warnings:
{savePassword && (
<p className="text-xs text-amber-600">
Password will be stored in your browser.
Only use on trusted devices.
</p>
)}
And there's a "Forget & Lock" button to clear stored passwords and re-lock the paste.
Size Limits
Encrypted pastes have a 75KB limit (vs 100KB for normal). Base64 encoding and the salt/IV overhead add ~35% to the stored size.
What I Gained
| Feature | Before | After |
|---|---|---|
| Server can read content | ✅ Yes | ❌ No (encrypted) |
| Password recovery | N/A | ❌ Impossible |
| Share sensitive content | ❌ Risky | ✅ Safe |
| Self-decrypting links | ❌ No | ✅ URL hash |
| Encryption algorithm | N/A | AES-256-GCM |
| Key derivation | N/A | PBKDF2 (310k iterations) |
Try It Out
Head to mdbin.sivaramp.com, toggle to Encrypted mode, and paste something sensitive.
Here's a test you can try:
- Create an encrypted paste with password
test123 - Note how the URL is
/e/[id]instead of/p/[id] - Share the link as
https://mdbin.sivaramp.com/e/[id]#test123 - Open in incognito—it auto-decrypts
Streamdown Plugin Update
One more thing: Streamdown moved to a plugin architecture in a recent update. The new setup looks like this:
import { createCodePlugin } from '@streamdown/code'
import { mermaid } from '@streamdown/mermaid'
import { math } from '@streamdown/math'
const code = createCodePlugin({
themes: ['github-light', 'github-dark'],
})
<Streamdown plugins={{ code, mermaid, math }}>
{content}
</Streamdown>
Same great features, more modular architecture. I updated the home page to highlight all three new capabilities: Mermaid diagrams, Math/LaTeX, and end-to-end encryption.
Bonus: Theme Toggle & Responsive Navbar
While I was at it, I added two quality-of-life improvements that deserved their own deep dive.
Dark Mode Toggle with next-themes
Previously, MdBin only respected prefers-color-scheme—you got whatever your OS dictated. Now there's a proper theme toggle.
The Setup
First, install next-themes:
bun add next-themes
Create a ThemeProvider wrapper:
// src/components/theme-provider.tsx
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
)
}
Key config:
-
attribute="class"— Adds.darkclass to<html>instead of using data attributes -
enableSystem— Respects OS preference when set to "system" -
disableTransitionOnChange— Prevents flash-of-wrong-theme during hydration
Tailwind CSS v4 Dark Mode
Here's the trick: Tailwind v4 uses a different syntax for custom variants. In globals.css:
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
This enables class-based dark mode alongside Tailwind's existing dark: utilities. All those dark:bg-gray-900 classes now work with next-themes.
The Toggle Component
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { Sun, Moon, Monitor } from 'lucide-react'
export function ThemeToggle() {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
// Avoid hydration mismatch
useEffect(() => setMounted(true), [])
if (!mounted) return <div className="w-9 h-9" /> // Placeholder
const cycleTheme = () => {
if (theme === 'light') setTheme('dark')
else if (theme === 'dark') setTheme('system')
else setTheme('light')
}
return (
<button
onClick={cycleTheme}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label={`Current theme: ${theme}`}
>
{theme === 'light' && <Sun className="w-5 h-5" />}
{theme === 'dark' && <Moon className="w-5 h-5" />}
{theme === 'system' && <Monitor className="w-5 h-5" />}
</button>
)
}
The mounted check prevents hydration mismatches—next-themes doesn't know the theme until client-side JavaScript runs.
Responsive Hamburger Menu
The header was getting crowded on mobile: Copy Link, Raw, New Paste, plus the new theme toggle. Instead of cramming tiny buttons, I added a hamburger menu below the md: breakpoint.
Desktop vs Mobile
<div className="flex items-center gap-2">
{/* Desktop: full button row */}
<div className="hidden md:flex items-center gap-2">
<button onClick={handleCopy}>Copy Link</button>
<Link href={`/p/${pasteId}/raw`}>Raw</Link>
<Link href="/">New Paste</Link>
</div>
{/* Always visible */}
<ThemeToggle />
{/* Mobile: hamburger */}
<div className="md:hidden relative">
<button onClick={() => setIsMenuOpen(!isMenuOpen)}>
{isMenuOpen ? <X /> : <Menu />}
</button>
{isMenuOpen && <DropdownMenu />}
</div>
</div>
Click-Outside & Escape Key Handling
Two patterns I always include for dropdowns:
const menuRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
// Close on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
menuRef.current &&
buttonRef.current &&
!menuRef.current.contains(event.target as Node) &&
!buttonRef.current.contains(event.target as Node)
) {
setIsMenuOpen(false)
}
}
if (isMenuOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isMenuOpen])
// Close on Escape
useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') setIsMenuOpen(false)
}
if (isMenuOpen) {
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}
}, [isMenuOpen])
Only attach listeners when the menu is open. Clean them up on close. No memory leaks.
The Dropdown
{isMenuOpen && (
<div
ref={menuRef}
className="absolute right-0 top-full mt-2 w-48 bg-white dark:bg-gray-900
border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-2"
>
<button onClick={() => { handleCopy(); setIsMenuOpen(false) }}>
Copy Link
</button>
<Link href={`/p/${pasteId}/raw`} onClick={() => setIsMenuOpen(false)}>
Raw
</Link>
<Link href="/" onClick={() => setIsMenuOpen(false)}>
New Paste
</Link>
</div>
)}
Each action closes the menu. The absolute right-0 top-full positions it below the hamburger button, aligned to the right edge.
Small details, but they matter for usability.
What's Next
With rendering and encryption sorted, the roadmap is clear:
- Expiration options — 1 hour, 1 day, 1 week, or permanent
- Edit links — Update pastes with a secret token
- Syntax-aware editor — CodeMirror or Monaco for the input
- Paste forking — Duplicate and modify existing pastes
The foundation is solid. The features are useful. Now it's about polish and power-user capabilities.
TL;DR: Added end-to-end encryption to MdBin using AES-256-GCM with PBKDF2 key derivation (310k iterations). Server never sees your plaintext or password. Share sensitive content via self-decrypting URL hash links. Also: Streamdown plugin architecture upgrade, dark/light/system theme toggle with next-themes + Tailwind v4 class-based dark mode, and responsive hamburger menu with proper click-outside and escape key handling.
Check out the encrypted paste feature at mdbin.sivaramp.com
Top comments (0)