How it works
Nobody counts. Nobody peeks. Everybody checks.
Three promises, and the mechanism behind each one. The short version is that a ZeroVote election has no trusted role in it — no counter, no key holder, no observer with an early look.
In plain terms
You list the options and the voters. Each voter gets a private link. In their browser, their choice is encrypted with a key that does not exist yet, and the sealed result is posted to a public board along with a proof they can keep.
At the time you set, a public randomness network publishes the value that unlocks every ballot at once. The count is arithmetic over the now-readable board. Everyone learns the outcome in the same instant, and anyone who doubts the number can redo it.
Nothing on the board says who cast which ballot, and that is not a policy — the information is never created. Read on for why.
1. No early results
Each ballot is encrypted to a specific future round of drand's Quicknet chain — a network of independent organizations that emits a publicly verifiable random value every three seconds using threshold BLS signatures on BLS12-381.
The value for round N does not exist until round N happens, and no single member of the network can produce it alone. So before the reveal time, decrypting a ballot is not forbidden, it is impossible. There is no key for us to hold, leak, subpoena, or lose.
When the round lands, the beacon signature is the decryption key for every ballot bound to that round. We verify it with a pairing check before using it, and we publish it with the results so you can confirm it against drand yourself.
// In the voter's browser, at vote time
ciphertext = tlock_encrypt(choice, drand_public_key, round)
// At reveal time — anyone can do this, including you
beacon = fetch("https://api.drand.sh/.../public/" + round)
verify_bls(beacon.signature, round)
choice = tlock_decrypt(ciphertext, beacon.signature)2. Nobody knows who voted how
Secrecy here comes from unlinkability, not from hiding the ballots. A ballot is authorised by a credential that the server signs without ever seeing it, using RSA blind signatures (RFC 9474, RSABSSA-SHA384-PSS-Randomized):
- Blind.The browser generates a random credential and blinds it with the election's RSA public key. Only the blinded form is sent.
- Sign.The server checks the voter is on the roster, verifies a signature made with the voter's own key, and signs the blinded value. It cannot see what it just signed.
- Unblind. The browser recovers a valid signature on the real credential. The ballot is submitted with that credential and nothing else — no invite token, no account, no slot number.
The server can verify the credential is one it signed and cannot tell which signing request produced it. The consequence is worth stating plainly: we cannot tell you whether a particular person has voted, and neither can the organizer. Not “we won't” — the query has no answer. That is also why your receipt lives in your browser rather than in our database.
Casting twice is prevented by a nullifier: a one-way hash of the credential, unique per credential and tied to no identity. It goes on the public board, so a duplicate would be visible to everyone, not just to us.
3. One voter, one ballot
Blind signing raises an obvious question: if the server issues credentials, what stops it from issuing extras and stuffing the box? The answer is that it cannot do so silently.
Every credential request is published in a registration log, and each entry carries a signature made with that voter's own key over the challenge and the blinded credential. The server does not hold those keys and cannot forge those signatures. So the number of credentials that can legitimately exist is bounded by a public list nobody can fake, and ballots ≤ signed registrationsis a comparison any observer can make on the election's page.
What this does and doesn't give you
4. The public board
Every accepted ballot is appended to a BLAKE3 Merkle tree. The leaf commits to the ballot id, the ciphertext, and the nullifier, each length-prefixed and tagged so no two distinct field sets can collide and no leaf can be mistaken for an internal node. The root is bound to the leaf count as well, so the tree cannot be silently truncated.
leaf = BLAKE3( LEAF_TAG || len(ballot_id) || ballot_id || len(encrypted_vote) || encrypted_vote || len(nullifier) || nullifier ) root = bind(leaf_count, reduce(leaves))
Submitting a ballot returns an inclusion proof and the root it was committed under. Your browser checks that proof immediately and stores it. If your ballot were ever removed or altered, your proof would stop matching the published root — which is what makes the receipt worth keeping.
The whole board is public while voting is open: ciphertexts, nullifiers, arrival order, and the registration log. Publishing sealed ballots early costs nothing, because nobody can read them, and it means the board cannot be quietly rewritten after the fact.
5. The count
At the reveal, the beacon is fetched and its BLS signature verified, every ballot on the board is decrypted, and the choices are counted. It is one deterministic pass with no human in it and no discretion available to anyone.
Ballots are single-choice: pick exactly one option. That constraint is what makes this whole design simple enough to be trustworthy. Because the multiset of choices is the tally, publishing every decrypted ballot reveals exactly the result and not one bit more — so there is nothing to protect with validity proofs, and no trustee needed to hold a decryption share.
A ballot that decrypts to nothing meaningful, or names an option that doesn't exist, is counted as spoiled rather than dropped. Every published number reconciles: sum(totals) + spoiled = total ballots. The arithmetic is printed on the results page so you can check it at a glance.
Add a “None of these” option if abstaining should be a real choice — spoiled is a count of malformed ballots, not a place to register an opinion.
6. Verifying it yourself
The board and the beacon are both public, so recomputing an election needs nothing from us but the data we already published. The open-source zerovote-verifier crate is a library and a CLI: it fetches a snapshot, pulls the beacon from drand directly, decrypts every ballot, rebuilds the Merkle tree, recomputes the tally, and compares the answer to the announced one.
$ zerovote-verify --slug board-election-2026 --api https://zerovote-api.fly.dev
{
"event_name": "Who should chair the board next year?",
"total_ballots": 47,
"beacon_verified": true,
"merkle_root_match": true,
"results_match": true,
"decrypt_failures": 0,
"duplicate_nullifiers": 0,
"valid_registrations": 47,
"invalid_registrations": 0,
"more_ballots_than_registrations": false,
"tally": { "winner": 0, "is_tie": false, "totals": [20, 15, 12], "spoiled": 0, "counted": 47 },
"ballots": [ ... one entry per ballot, with its decrypted choice ... ]
}
VERIFIED: 47 ballots, root matches, published result matches the recomputed tally.It exits non-zero and names what failed, rather than printing numbers and leaving the comparison to you — that is how a rigged election passes review.
Three checks are worth doing separately, because they fail differently: that the beacon signature is genuinely drand's, that the board's root matches what was published while voting was open, and that the ballot count does not exceed the signed registrations.
For integrators there are webhooks that fire at the reveal and a public API for status and results. A market can settle the moment drand publishes the round, without waiting on our server at all.
What is guaranteed
Pre-reveal confidentiality. No party can read any ballot before the drand round, because the key does not exist yet. This holds even against us, with full database access.
Unlinkability. Blind signatures mean no record anywhere connects a voter to a ballot. The link is never created, so it cannot be leaked, compelled, or sold.
Bounded electorate. Every credential leaves a voter-signed entry in a public log. Extra ballots require extra entries that no voter signed, visible to any observer.
One ballot per credential. A published nullifier, unique per credential, enforced atomically. A second attempt is rejected.
Tamper evidence. A BLAKE3 Merkle tree bound to its own leaf count, with every voter holding an inclusion proof against a root published before the reveal.
Independent recount. Everything needed to reproduce the result is public and the verifier is open source. Checking us requires no cooperation from us.
Limits
A page like this is only worth reading if it also says what the system does not do.
- Your device is trusted. Encryption happens in your browser, so malware on your machine could see your choice as you make it. No remote system can fix that.
- Coercion resistance is not claimed. Your receipt proves your ballot is on the board and, after the reveal, lets you read your own vote off it. Someone standing over you could demand to see that.
- Small elections leak by arithmetic. In a three-voter election a unanimous result tells everyone how everyone voted. That is inherent to publishing a tally, not a flaw in the cryptography.
- The roster is the organizer's responsibility. We enforce one ballot per invited address; we cannot know whether the list of addresses was the right one.
- We serve the code that does the encrypting.A malicious server could ship a bad script to a voter's browser. Reproducible builds and a published bundle hash are the fix, and are not in place yet.
Implementation
Timelock
tlock on drand Quicknet (BLS12-381)
Merkle tree
BLAKE3, tagged and length-prefixed
Blind signatures
RSA-PSS, RFC 9474
Client crypto
Rust compiled to WebAssembly
Voter keys
Ed25519, generated per invite in the browser
Verifier
Open-source CLI and library crate
Run one and see
Creating an election takes about two minutes. Nothing above requires you to understand it in order to use it.
Why I built this