
Rolan LoboI'm going to be honest with you. It started because I watched a spy movie, thought "this message...
I'm going to be honest with you.
It started because I watched a spy movie, thought "this message will self-destruct in 5 seconds" was the coolest thing ever, and immediately opened VS Code instead of going to sleep like a normal person.
Seven days, one developer (me, just me, no team, no co-founder, no intern, nobody — just me, my laptop, and an unhealthy amount of coffee), I had Burn Chat running.
Here's what it does: you create a chat room with a countdown timer. Share the link. People join. You talk. When the timer hits zero, every single message on every single screen disappears. The server deletes everything. There's no history, no logs, no exports, no "deleted messages" folder. It's just... gone. Forever.
This is the story of how I built it, what I got completely wrong, and the parts I'm actually proud of.
👉 Try it live: bar-rnr.vercel.app/burn-chat
The feature list I had in my head at 2am:
That last one is important. If I'm going to call this "ephemeral", it had better actually be ephemeral. Not "ephemeral but we keep logs for 30 days for compliance reasons." Actually gone.
Your Browser My Server Their Browser
──────────────── ────────────── ──────────────
Generate keys Generate keys
Send public key ──────────► store in RAM ────────────► get public key
Encrypt message ──────────► relay blob ────────────► decrypt message
(AES-GCM) server sees ????base64???? (AES-GCM)
literally has no idea
what it says
The server is a blind courier. It sees base64 blobs going in one WebSocket and coming out another. It has no idea what the messages say. It stores nothing on disk.
This is not a marketing claim. The server genuinely cannot decrypt the messages because it never has the keys.
# I could have used Redis. I used this.
_SESSIONS: Dict[str, _ChatSession] = {}
Yes. A plain Python dictionary. In memory. On one process.
You might be thinking: "that's not scalable."
You're right. It's also exactly what I needed. Here's my thinking:
If I store sessions in Redis, Redis is a database. Databases write to disk. If data touches disk, it can be recovered. I'm building a feature whose entire selling point is that data CANNOT be recovered. A plain in-memory dict that disappears when the process restarts is a feature, not a bug.
I'm one person. I have one dyno. It works. When I need to scale, I'll figure out sticky sessions + Redis with a EXPIRE key. That day is not today.
async def _countdown_loop(token: str, session: _ChatSession) -> None:
try:
while True:
now = datetime.now(timezone.utc)
remaining = (session.expires_at - now).total_seconds()
if remaining <= 0:
break
# Coarse ticks when there's plenty of time.
# 1-second ticks in the final minute for smooth UI animation.
interval = 1.0 if remaining <= 60 else 10.0
sleep_for = max(0.05, min(interval, remaining))
await asyncio.sleep(sleep_for)
# ⚠️ Always recompute from the wall clock.
# Never trust sleep() duration — it drifts under load.
remaining = (session.expires_at - datetime.now(timezone.utc)).total_seconds()
await _broadcast(session, {
"type": "countdown",
"seconds_remaining": max(0, int(remaining))
})
except asyncio.CancelledError:
return
await _destroy_session(token)
The important bit: always recompute remaining from the actual wall clock. Never decrement a counter. Python's asyncio.sleep() can sleep longer than you asked if the event loop is busy. If you accumulate sleep durations, your 10-minute timer ends up being 11 minutes. Recomputing from expires_at - now every tick keeps it accurate.
async def _destroy_session(token: str) -> None:
session = _SESSIONS.get(token)
if session is None:
return # already gone, nothing to do
# Step 1: Tell EVERYONE to show the fire animation.
await _broadcast(session, {"type": "destroyed"})
# Step 2: Close every WebSocket connection.
for participant in list(session.participants.values()):
try:
await participant.ws.close(code=1000, reason="Session expired")
except Exception:
pass
# Step 3: Delete from memory. This IS the burn.
_SESSIONS.pop(token, None)
Order is important here. Broadcast first — otherwise you'd close the connections before clients know to show the animation. Everyone sees the fire at the same time because the broadcast goes out in a single loop before any connections are closed.
I want to be upfront: cryptography is hard. I read a lot. I tested a lot. I still probably got some edge case wrong. But here's what I implemented and why.
Every person who joins generates an ECDH P-256 keypair in their own browser:
const keypair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false, // ← this means the private key CANNOT be exported
['deriveKey']
);
See that false? That's extractable: false. The Web Crypto API gives you a hard guarantee: even if some JavaScript on the page tries to export that private key, the browser will refuse. The private key is born in your browser and dies in your browser.
Each participant broadcasts their public key to the server. The server stores it (as an opaque blob it can't use for anything) and relays it to everyone else:
// You send this when you connect
ws.send(JSON.stringify({
type: 'pubkey',
public_key: await exportPublicKey(myKeypair)
}));
The creator then derives a shared ECDH secret with each participant, generates one AES-GCM-256 session key, and sends each person a version wrapped (encrypted) with their specific shared secret:
async function wrapSessionKey(sessionKey, peerPublicKeyB64, myPrivateKey) {
// Derive a shared secret using our private key + their public key
const sharedSecret = await crypto.subtle.deriveKey(
{ name: 'ECDH', public: await importPeerPublicKey(peerPublicKeyB64) },
myPrivateKey,
{ name: 'AES-KW', length: 256 },
false,
['wrapKey']
);
// Wrap the session key with the shared secret
const wrapped = await crypto.subtle.wrapKey('raw', sessionKey, sharedSecret, 'AES-KW');
return btoa(String.fromCharCode(...new Uint8Array(wrapped)));
}
The server receives an opaque blob for each recipient and delivers it. It has no idea what's inside. Even if someone hacked my server right now while you were chatting, they'd get base64 they can't do anything with.
Every message gets a fresh 12-byte random IV:
async function encryptMessage(text, sessionKey) {
const iv = crypto.getRandomValues(new Uint8Array(12)); // fresh every time
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
sessionKey,
new TextEncoder().encode(text)
);
return {
ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))),
iv: btoa(String.fromCharCode(...iv)),
};
}
Fresh IV per message is non-negotiable. I'll explain why in the mistakes section.
After key exchange, everyone computes this:
async function deriveFingerprint(sessionKey) {
const raw = await crypto.subtle.exportKey('raw', sessionKey);
const hash = await crypto.subtle.digest('SHA-256', raw);
const bytes = new Uint8Array(hash);
return Array.from(bytes.slice(0, 3))
.map(b => b.toString(16).padStart(2, '0'))
.join(''); // something like "a3f9c2"
}
Everyone in the session sees the same 6-character hex code. If you're paranoid, read it out loud to the other person. If your codes match, the key exchange wasn't tampered with. Is it perfect? No. Is it better than nothing? Absolutely.
The entire chat UI lives in one state variable:
const [phase, setPhase] = useState('join');
// 'join' → name entry, waiting to connect
// 'chat' → connected, messaging, countdown ticking
// 'destroyed' → fire animation, then... nothing
Simple. No routing. No URL changes. Just a state string that drives which thing you see.
The part I spent way too long on and absolutely do not regret:
// BurningAnimation.jsx
// mode="chat" for Burn Chat, mode="file" for file destruction
{mode === 'chat' ? (
<div className="text-center">
<Flame size={56} className="text-orange-400 animate-pulse mx-auto" />
<h2 className="text-2xl font-bold text-orange-400 mt-4">
Session Burned
</h2>
<p className="text-gray-400 text-sm mt-2">
All messages have been permanently erased. No trace remains.
</p>
</div>
) : (
<div className="text-center">
<FileX size={56} className="text-red-400 mx-auto" />
<h2 className="text-2xl font-bold text-red-400 mt-4">
File Destroyed
</h2>
</div>
)}
I reused the same animation component for file destruction and chat destruction, added a mode prop, and now it knows which version to show. The timing, the progress bar, the fade — all identical. Different icon, different text, different feeling.
When you create a session, you get a PIN. Enter it when you connect and you unlock: kick participants, lock the room so no one new can join, and extend the countdown. The PIN is sent over the WebSocket (under TLS) and compared server-side with secrets.compare_digest() — constant-time comparison so you can't do timing attacks to guess digits one by one.
Three wrong PINs in 10 minutes and you're locked out. I'm not taking any chances with brute force.
Early version. I generated one IV when the session was created and reused it for every message.
This is catastrophically wrong with AES-GCM.
Reusing an IV with the same key doesn't just "weaken" the encryption. It breaks it completely. An attacker who collects two messages encrypted with the same key and same IV can XOR the ciphertexts and recover the XOR of the plaintexts — which combined with any knowledge of one message's content, reveals the other. The 96-bit IV space with fresh random IVs per message means you'd need to send ~4 billion messages before a collision becomes likely. Fine.
Fresh IV every single time. No exceptions.
Alice creates the session. Bob joins. They exchange public keys. The session key is distributed.
Then Carol joins.
Carol never received Bob's public key broadcast. Bob's not going to re-send it — he already sent it. Carol can't decrypt anything Bob sent before she joined.
The fix: store every participant's public key on the server-side participant object. When anyone joins, the server includes every existing participant's public key in the joined response. Carol gets everyone's keys in one message the moment she connects, no matter when she joins.
@dataclass
class _Participant:
ws: WebSocket
ws_id: str
name: str
is_creator: bool = False
public_key: Optional[str] = None # ← stored here, sent to late joiners
First implementation:
# WRONG — don't do this
remaining = ttl_seconds
while remaining > 0:
await asyncio.sleep(1)
remaining -= 1 # ← this drifts
Under any real server load, asyncio.sleep(1) sleeps for 1.003 seconds, or 1.01 seconds, or however long the event loop was busy. Multiply that over 600 ticks for a 10-minute session and your timer is off by 6–10 seconds.
The fix is what I showed earlier — always compute remaining = expires_at - datetime.now(). The wall clock doesn't drift.
Let's trace the exact moment a session ends:
_countdown_loop wakes up, computes remaining ≤ 0
_destroy_session(token)
{"type": "destroyed"} to every WebSocket simultaneouslyphase = 'destroyed'
_SESSIONS.pop(token) — Python garbage collects the dict entryFrom step 3 to step 8, the real-world time is milliseconds. There's no race where one person sees the burn and another doesn't for a few seconds.
I'm not going to pretend this is Signal. Here's what it actually does:
Protects you from:
Does NOT protect you from:
Use this for: private conversations you genuinely want to disappear.
Don't use this for: evading law enforcement, anything actually illegal, evidence tampering.
Things I'd add if I weren't one person doing this in my spare time:
Redis + sticky sessions — right now the in-memory dict means one server instance. Horizontal scaling would need Redis (but with careful thought about what goes in Redis vs. stays ephemeral).
A proper ratchet — the current design uses one session key for all messages. The Double Ratchet algorithm (what Signal uses) gives each message its own key derived from the previous one — compromise of one message doesn't reveal any others. Cool. Complex. Maybe v2.
Forward secrecy at the server layer — currently if somehow the session key leaked, all past messages in that session could be decrypted. A proper ratchet would fix this.
The fingerprint experience — right now it's a code you can optionally compare. It should be more prominent, maybe with a visual comparison like Signal's Safety Numbers.
Two tabs. Open them both.
Everything disappears. Both tabs. At the same time.
It's a small thing but it never gets old.
Source code: github.com/Mrtracker-new/BAR_RYY
Built by one person, alone, late at night, because spy movies are inspiring and sleep is overrated. If you find a bug, open an issue. If you find a security hole, please tell me before you do anything fun with it. 🔥