
Voto Livre: Privacy-First Anonymous Reporting in 3 Days
A free site for Brazilians fired, threatened or boycotted over their vote in 2026, built in about three days, with anonymous reports and nothing that tracks visitors.
The ballot in Brazil is secret. That doesn't stop an employer or a WhatsApp group from deciding they know how someone voted, and acting on it.
Voto Livre is a free guide for people who were fired, threatened or boycotted because of their vote in the 2026 elections. It has practical guides for workers and for business owners, with evidence checklists and message templates, official reporting channels for the whole country and each of the 27 states, contacts for 129 federal deputies from left and center-left parties pulled from the Chamber of Deputies open data API, and a map of cases per state. Every page opens with the emergency number.
I conceived and built it, and it was online in about three days, from the first commit to production with its own domain, bot protection and reports forwarded to public prosecutors. I wrote 18 of its 19 commits. The other one came from a volunteer who connected the Ministry of Labor's electoral harassment channel.
Most of the engineering went into a single form, the anonymous report, and its users are people who could face retaliation. I designed it around one question: what does the site need to know, and what can it refuse to learn?
1. In the browser
The form has no field for a name, an email or a phone number. Before the report leaves the device, the text is checked for anything that looks like a CPF, CNPJ, phone number or email, and a match blocks the send. The server runs the same check again. The site's privacy page says plainly that this filter is automatic and can't replace the writer's own care.
The browser also generates the report's UUID. If the connection drops and the person presses send again, the report counts once and uses no extra quota. A Turnstile challenge and a honeypot field deal with bots, and a bot that fills the honeypot gets a fake protocol number instead of an error.
There's no analytics script, and the fonts come from the site itself, so no third party learns who visited.
2. At the edge
A Cloudflare Pages Function receives the report. It reads the body as a stream and stops at 12 KB, whatever Content-Length the client claims, checks the origin and the content type, and verifies that the Turnstile token was issued for this domain and for this action.
Then comes the rate limit, which had to work without keeping anyone's IP address. Its key is an HMAC of the IP with a secret that changes every day, and IPv6 addresses are grouped by /64, the smallest block a subscriber usually gets. The counter lives in D1 and goes up in a single INSERT ... ON CONFLICT ... RETURNING statement. My first version used KV and read the count before writing it back, which left a time-of-check to time-of-use race: enough parallel requests could all read the same number and all get through.
3. In the database
D1 keeps the answers from the form's checkboxes: state, type of case, who is reporting (the person affected, a witness or an organization), when it happened, and the date of the report without the time. Those rows feed the per-state counts. Storing the report text and the city is a setting someone has to turn on, and stored text is deleted after 180 days. There's no IP, user agent or cookie anywhere in it.
A database that never had the name can't leak it.
4. In the email
This is where the report leaves the site. It goes by email, through Resend or Cloudflare Email Service, to the ombudsman office of the public prosecutor in the person's state, with a copy to the site's administrator. The message carries the report and nothing about who sent it.
The routing has its own rules. Brazil's Labor Prosecutor's Office (MPT) takes reports only through its petition system, so for workplace cases the email asks the state ombudsman to forward the report to the competent Regional Labor Prosecutor's Office, citing the right of petition in article 5, XXXIV of the Constitution. Reports from business owners cite the criminal offenses that may apply. When a state's prosecutor has no email channel, the report goes to the national human rights ombudsman, the office behind the Disque 100 hotline. Each address in the routing table was checked, and its source is recorded next to it.
The person also gets the same message on their own device, ready to send to Disque 100 by WhatsApp or email, or to paste into the MPT's form, from their own account.
A copy mode let me run the whole flow end to end before a single email reached a real agency.
5. On the map
The public map shows cases per state on a hand-built 9-by-8 tile-grid cartogram, with one tile of the same size for every state, so Sergipe stays as readable on a phone as Minas Gerais. The numbers behind it are cached at the edge for 10 minutes. That saves requests, and it also stops anyone from watching the counter to match a jump with the moment a specific person pressed send.
What anonymous means here
The site can't tell who sent a report. Cloudflare, which hosts it, keeps its own access logs like any host, and the privacy page says so. Storing the date without the time makes it much harder to line up a database row with one of those log entries.
What the review caught
While building it, I ran several rounds of adversarial security review on my own code, reading each endpoint the way an attacker would. They found five real problems, all fixed:
- a DOM XSS in the Open Graph image generator
- the race condition in the KV rate limiter
- a way around the request body size limit
- a rate limiter that turned itself off silently when its configuration was missing
- gaps in origin and content-type validation
The fourth one still bothers me. A limiter that switches off when a setting is missing passes any test that doesn't look for it, and nothing breaks to tell you.
Small on purpose
A build.py of about 200 lines, using only Python's standard library, generates the HTML locally, and the output goes into the commit, so a deploy has no build step. The JavaScript has no dependencies, and the back end plus scripts come to about 870 lines. The deputies list comes from a script that pages through the Chamber's API, backs off on 429 and 5xx errors and takes the legislature as a parameter, so it can be refreshed when the new Congress takes office in 2027.
It runs on Cloudflare's free plan, so it costs close to nothing and any volunteer can edit and publish it. The code is MIT and the content CC BY 4.0, and one outside contribution has already come in.
The text is set in Atkinson Hyperlegible, a typeface designed for low-vision readers. The mobile menu manages focus and aria-expanded, and updates are announced through aria-live regions. One commit went entirely to plain language: a shorter form, a result screen with next steps, and a summary at the top of each guide. People who are scared read less, so the summary comes first.
Earlier that month I volunteered on Tech Contra Bolsonaro and Radar da Virada. Voto Livre is the one I started myself, built on Cloudflare Pages, Pages Functions, D1, Turnstile and Web Crypto.
What does your database know about your users that it doesn't need to?