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
What I Was Actually Going For
The feature list I had in my head at 2am:
- β±οΈ Set a countdown timer (5 min to 24 hr)
- π End-to-end encryption β the server should never be able to read messages
- π₯ When the timer hits zero, everyone sees a fire animation simultaneously
- π One link to share, no accounts, no sign-up
- π A PIN so the creator can kick people, lock the room, extend the timer
- π« Absolutely nothing stored on disk or in a database
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.
The Architecture (Or: What I Drew on a Napkin)
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.
Part 1: The Backend β Python, FastAPI, and Some Questionable Choices
I Used a Dictionary to Store Sessions
# 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.
Each Session Gets a Timer
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.
Burning the Session
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.
Part 2: The Crypto β The Part That Made My Brain Hurt
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.
Key Generation
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.
The Key Exchange
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.
Encrypting Messages
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.
The Session Fingerprint
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.
Part 3: The Frontend β React, Fire, and a State Machine
Three Phases, One Component
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 Burn Animation
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.
Creator Controls
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.
The Mistakes (a.k.a. The Educational Section)
Mistake 1: I Reused the IV
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.
Mistake 2: I Forgot About Late Joiners
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
Mistake 3: I Decremented the Timer Instead of Using the Wall Clock
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.
What Actually Happens When It Burns
Let's trace the exact moment a session ends:
-
_countdown_loopwakes up, computesremaining β€ 0 - Calls
_destroy_session(token) - Server broadcasts
{"type": "destroyed"}to every WebSocket simultaneously - Every browser receives the message in the same event loop iteration
- Every client's React state changes to
phase = 'destroyed' - Every screen shows the flame animation at the same moment
- Server closes all WebSocket connections
-
_SESSIONS.pop(token)β Python garbage collects the dict entry
From 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.
The Honest Security Section
I'm not going to pretend this is Signal. Here's what it actually does:
Protects you from:
- β Someone hacking my server and reading messages (server only has ciphertext)
- β Passive network interception (TLS + E2E encryption)
- β Accidentally leaving a chat history lying around
- β The "I sent that to the wrong person" anxiety (it's gone in 5 minutes anyway)
Does NOT protect you from:
- β The other person screenshotting your messages (classic)
- β Someone compromising your browser specifically
- β A sufficiently motivated adversary with physical access to a device
- β The session fingerprint requires you to actually compare codes out loud β if you skip that step, technically MITM is possible
Use this for: private conversations you genuinely want to disappear.
Don't use this for: evading law enforcement, anything actually illegal, evidence tampering.
If I Had More Time (And More Energy)
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.
Try It
Two tabs. Open them both.
- Go to bar-rnr.vercel.app/burn-chat
- Create a session with a 5-minute timer
- Copy the link, open it in the second tab
- Send some messages between the tabs
- Wait for the timer, or just wait for the fire
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. π₯
Top comments (0)