What I Learned Building a TLSNotary Chrome Extension

There's a gap in how we think about web data. We trust HTTPS to keep our connections private, but we can't prove to anyone else what we saw. If Twitter shows me I have 10,000 followers, I can screenshot it — but screenshots are trivially faked. There's no cryptographic way to prove "this is what Twitter's API actually returned."
TLSNotary fills that gap. It lets you create a cryptographic proof of any HTTPS response without the server knowing or participating. I built a Chrome extension called LupoVerify that uses TLSNotary to prove Twitter data — profile ownership, tweet authorship, engagement actions — all without revealing your auth cookies or tokens.
This post is about what I learned building it.
The Core Idea: Making HTTPS Proofs Possible
Normal HTTPS works like this: your browser and the server establish a TLS connection, negotiate encryption keys, and exchange encrypted data. Only you and the server can read the traffic. This is great for privacy, terrible for provability.
TLSNotary introduces a third party — the Notary — into the TLS handshake, but in a clever way. The Notary participates in the cryptographic protocol without ever seeing the plaintext data. It's called MPC-TLS (Multi-Party Computation over TLS).
Here's the mental model that helped me understand it:
Normal TLS:
Browser ←——encrypted——→ Server
(only these two can decrypt)
MPC-TLS:
Browser ←——encrypted——→ Server
↕
Notary (helps with crypto, never sees plaintext)
The browser and the Notary jointly hold the TLS session keys through a 2-party computation. Neither party alone can decrypt the traffic. But together, they performed the TLS handshake correctly, which means the Notary can attest: "I participated in a valid TLS session with twitter.com, and the browser's claimed response is authentic."
The browser then selectively reveals parts of the response (like "screen_name": "lupo0x") while redacting everything else (auth tokens, cookies, other fields). The result is a proof that says: "Twitter's API returned this specific data at this specific time" — and anyone can verify it without contacting Twitter.
Architecture: What the Extension Actually Does
LupoVerify is a Manifest V3 Chrome extension built with React and TypeScript. The architecture has more moving parts than I expected:
Background Service Worker — Intercepts HTTP requests using chrome.webRequest, logs them, and manages the notarization lifecycle. It also creates an offscreen document for running WASM workers (more on that below).
Offscreen Document — This was a fun one. Chrome's Manifest V3 doesn't allow long-running background scripts, so heavy computation (like MPC-TLS) needs to happen in an offscreen document with Web Workers. The service worker creates it on startup and communicates via chrome.runtime.sendMessage.
Notarize Flow — When you want to prove a request:
- You browse Twitter normally — the extension captures requests
- You select a request to notarize
- The extension replays the request through a WebSocket proxy to a TLSNotary Notary server
- MPC-TLS runs between the extension and the Notary
- You choose what to reveal and what to redact
- A proof is generated locally
WASM Plugins — The extension supports Extism-based WASM plugins for different "quests" (proof types):
quest-1-profile.wasm → Proves you own a Twitter profile
quest-2-tweet.wasm → Proves you authored a specific tweet
quest-3-engagement.wasm → Proves you liked/retweeted something
Each plugin defines what URL to hit, what headers are needed, and how to extract the relevant fields from the response.
The Notarization Process Up Close
The part that took me the longest to understand was the actual notarization step. Here's what happens under the hood:
// Simplified from the actual code
chrome.runtime.sendMessage({
type: 'prove_request_start',
data: {
url: 'https://api.twitter.com/1.1/account/verify_credentials.json',
method: 'GET',
headers: { Authorization: 'Bearer ...', Cookie: '...' },
secretHeaders: ['Authorization', 'Cookie'], // REDACT these
secretResps: [], // reveal response
notaryUrl: 'https://notary.example.com',
websocketProxyUrl: 'wss://proxy.example.com',
},
});The secretHeaders array is crucial — it lists which request headers should be hidden from the proof. You want to prove what Twitter returned, not leak your auth token. The Notary never sees these values, but it can still verify the TLS session was valid.
Two things surprised me:
-
Accept-Encoding: identityis required. If the response is gzip-compressed, the proof would be over compressed bytes, which aren't human-readable. You need the raw plaintext. I had to override this header explicitly. -
Connection: closematters. TLS session reuse can complicate the MPC protocol. Forcing the connection to close after each request keeps things clean.
What the Proofs Actually Contain
A TLSNotary proof includes:
- The server's TLS certificate chain (proves you talked to twitter.com)
- The Notary's signature over the session transcript
- The selectively disclosed parts of the response
- A commitment to the redacted parts (you can't change them later)
What it doesn't include:
- Your cookies or auth tokens
- Any redacted response fields
- Your IP address or identity
This is the key insight: the proof is about the data, not about you. Anyone can verify that Twitter's server returned specific data, without knowing who asked for it.
Privacy Model
Building this made me think carefully about what's actually private:
| Actor | What They See | |-------|---------------| | Twitter | Normal HTTPS request (no idea you're generating a proof) | | Notary | Encrypted TLS traffic only (never the plaintext) | | Verifier | Only the fields you chose to reveal | | You | Everything (you control what gets disclosed) |
The Notary is the interesting case. It participates in the TLS handshake cryptography but sees only ciphertext. It's like a witness who can confirm "a valid conversation happened with twitter.com" without knowing what was said.
What Bit Me
Offscreen document limitations. Manifest V3's offscreen API is powerful but quirky. You can only have one offscreen document at a time, it can be killed by Chrome at any time for resource management, and you need to check for its existence before creating it. I had to add retry logic and careful lifecycle management.
WebSocket proxy is mandatory. Browsers can't do raw TCP connections, which TLS requires. The extension routes through a WebSocket-to-TCP proxy. This adds a dependency and a potential point of failure. In production, you'd want to run your own proxy.
Proof generation is slow. The MPC-TLS protocol involves multiple rounds of communication between the browser and the Notary. On my machine, generating a proof takes 5-30 seconds depending on the response size. The UI needs a progress indicator or users think it's broken.
WASM plugin system was over-engineered for my use case. I used Extism for the plugin system, which is great for extensibility but adds complexity. For a simpler project, you could hardcode the quest logic directly.
Why This Matters Beyond Twitter
I built this for Twitter verification, but the TLSNotary pattern is much broader. You could prove:
- Bank statements — "My balance is over $X" without showing the exact amount
- Employment — "I work at Company Y" by proving an internal page response
- Age verification — prove you're over 18 from a government API without revealing your birthdate
- Price quotes — prove an exchange showed a specific price at a specific time
- Academic credentials — prove enrollment status from a university portal
Any HTTPS response from any website becomes provable. The website doesn't need to cooperate or even know it's happening.
Comparing to Other Approaches
| Approach | Trust Model | Server Cooperation | Privacy | |----------|-------------|-------------------|---------| | Screenshot | None (fakeable) | No | Full reveal | | API key (OAuth) | Trust the API provider | Yes (they must support it) | Provider sees all | | TLSNotary | Trust the math | No | Selective disclosure | | zkTLS (future) | Trust the math | No | Zero-knowledge |
TLSNotary sits in a sweet spot: it doesn't require server cooperation (unlike OAuth), it's cryptographically sound (unlike screenshots), and it supports selective disclosure (unlike data dumps).
The next evolution is full zero-knowledge proofs over TLS transcripts, where you prove properties of the data ("salary > $100K") without revealing the data itself. Projects like Pluto and Reclaim are working on this.
What I'd Do Differently
If I were starting over:
- Skip the plugin system initially. Start with hardcoded quest logic, add extensibility later
- Build a standalone verification page first. I jumped straight to the extension, but a simple web page that verifies proofs would have been a better MVP
- Use a simpler state management approach. Redux was overkill for this — React context or Zustand would have been cleaner
- Test with a local Notary from day one. Running against a remote Notary during development adds latency and failure modes
Try It Yourself
The extension is at github.com/LucianoLupo/lupo-verify-extension. To run it:
git clone https://github.com/LucianoLupo/lupo-verify-extension.git
cd lupo-verify-extension
npm install
npm run dev
# Load as unpacked extension in chrome://extensionsYou'll also need the TLSNotary Notary server and a WebSocket proxy running. The README has the full setup.
TLSNotary is still evolving fast (I used tlsn-core v0.1.0-alpha.12), but the core concept — making HTTPS responses provable without server cooperation — is one of the most underrated primitives in crypto right now.
Source code: github.com/LucianoLupo/lupo-verify-extension. The companion backend and Rust verifier are at github.com/LucianoLupo/zk-twitter-verifier-002.