In production2026-05 – nowSolo full-stack developer

Knoxis

A church's secret ballot had to be provable by looking at the schema, beyond a promise made in a policy.

  • django
  • postgresql
  • react
  • typescript
  • drf

The problem and constraints

Knoxis exists to run a specific, high-stakes ritual: a church electing presbyters and deacons by secret ballot, the way Presbyterian churches have done it for generations, with a paper ballot and multiple rounds of counting until every seat is filled by a real majority. The client wanted that ritual on a computer, and the one requirement that shaped everything else was that “secret” had to mean something stronger than “we promise not to look.”

That’s a sharper constraint than it sounds. A typical web form can log every request, keep every database row forever, and nobody outside the team ever notices. The cost of that carelessness is usually a slow query or a slightly bloated table. Here, the same carelessness (storing who voted next to what they voted for, even briefly, even “just for debugging”) is the one failure that would make the whole system worthless to a church that exists specifically because it doesn’t trust informal, undocumented processes to handle something this sensitive.

The second constraint was the shape of church governance itself, which doesn’t map onto a flat list of interchangeable “users.” A presbytery has multiple offices up for election at once, each with its own number of open seats; the rule for winning is a simple majority of votes cast, which routinely takes more than one round when nobody clears 50%; and the very last round can switch to “whoever got the most votes wins,” specifically to guarantee the process actually ends. None of that is exotic, but all of it needed to be represented faithfully: a “majority” calculation that’s off by one seat, or a system that can’t cleanly carry the same remaining candidates into a second round, would undermine the same trust the secrecy design was protecting.

The third constraint was scope discipline against real pressure to add things. The client was open to infrastructure like Redis if the system needed it (a real-time push notification, a background job queue, a distributed cache), and the honest answer, checked case by case, was that at the expected scale of a single congregation’s election, none of those needs were real yet. Adding Redis “to be safe” would have meant a fourth moving part in production, a bigger attack surface, and more to reason about for a system whose core promise is trustworthiness through simplicity; raw throughput was never the point.

The fourth constraint was that a CPF (the Brazilian national identifier used to check a voter against the church’s membership roll) is itself sensitive personal data under Brazilian law, and the system’s only real need for it is a yes-or-no match at the door. Anything beyond that minimum, including the temptation to just keep it around “in case it’s useful later,” was a liability with no corresponding feature to justify it.

The last constraint was multi-tenancy from day one: a platform where any number of congregations run their own elections, completely isolated from each other, without each one needing its own database, its own deployment, or its own migration history to keep in sync.

Architecture

A voter opens the ballot flow and identifies with a CPF. The backend normalizes it, validates the check digits, computes a keyed HMAC hash, and looks it up against the church’s voter roll for that specific election: a single indexed comparison, no different in speed from comparing plain text, except that the plaintext CPF is never the thing sitting in the database. A match issues a ballot_session: a short-lived token, handed back in an httpOnly cookie, that represents “this specific person is now allowed to cast this specific ballot” without yet saying anything about what they’ll choose.

Submitting the ballot is where the architecture’s real promise gets kept. In a single database transaction, the backend locks the ballot session row, confirms it hasn’t already been used and hasn’t expired, marks it used, and then writes to two separate tables: voter_attendance records that this voter participated in this round (it has a voter ID, because knowing turnout and preventing double-voting both require it), and votes records the actual choices, for each open office, with no voter ID, no CPF, no reference back to the ballot session at all. There is no foreign key a query could follow from one table to the other. The vote timestamp itself is truncated to the minute, which quietly reduces the value of trying to correlate “who was active at 7:42:03” with “what got recorded at 7:42:03,” an imperfect defense in a tiny election with long gaps between voters, but a real one at the scale this system actually runs at.

Every one of those tables, and every table in the system, carries an organization_id: the boundary that keeps one church’s election completely invisible to another. That boundary is enforced by a mixin that every API viewset in the system is required to inherit, which filters every read by the logged-in organization and silently injects the tenant on every write, ignoring anything a client might try to send instead, no separate database or Postgres row-level security policy involved. Because that guarantee lives in application code rather than the database engine itself, it’s backed by a second guarantee at the test level: a single regression test walks every registered viewset in the project and fails the build if any of them was written without the mixin.

While a ballot round is open, the organizer watches a live tally in a separate, admin-only view (never visible to voters), updated by polling the backend every three seconds rather than any push mechanism. An ETag built from the round’s ID, its status, and its current vote count means an unchanged tally costs the server almost nothing to answer: a 304 Not Modified and no query. When the round closes, the same aggregation query determines who cleared a real majority of the votes cast, and whoever remains short of a seat carries straight into the next round automatically, with the same candidates and the same open seats, until every position is filled, on the last round, by whoever received the most votes, exactly as the church’s own printed rules require.

The Knoxis live tally screen for an open ballot round, showing five votes counted so far and a ranked bar chart of vote counts per candidate for each office (Presbyter and Deacon), labelled 'partial results, do not disclose'. All names and vote counts shown are fictional test data.
The three-second polling view described above, mid-round: five votes counted, ranked live per office, with the standing 'do not disclose' label that only makes sense because this screen updates while voting is still open.

Architecture

Knoxis: secret ballot architecture A voter identifies with a CPF, which the backend hashes and matches against the voter list, then issues a short-lived ballot session token. Submitting the ballot writes to two tables in one transaction: voter_attendance, which records that this voter voted, and votes, which records the choices. There is no foreign key between them, so nothing in the schema can link a specific voter to a specific vote. ORGANIZATION_ID BOUNDARY (one church, isolated from every other) Voter identifies with CPF CPF → HMAC hash matched, never stored in clear ballot_session short-lived token Ballot submitted one transaction, two writes voter_attendance has voter_id votes no voter_id, no FK to voter no column, anywhere, links a vote back to a voter

Decisions

DECISION 01/04 · Secret ballot

ChosenSplit identity and vote into two tables with no foreign key between them: voter_attendance records that someone voted, votes records what was chosen, linked only by a ballot session token that's destroyed the instant the ballot is submitted

Discarded insteadStore (voter_id, candidate_id) directly and rely on policy not to query it that way

A promise not to look is not a guarantee. It's broken by a curious admin, a bug, or a subpoena. Splitting the tables makes the guarantee visible in the schema itself: there is no join that recovers who voted for whom

Cost acceptedResidual temporal correlation is still possible in a very small ballot round with a long gap between votes, mitigated by truncating vote timestamps to the minute, but not eliminated. Documented as an accepted limitation of this threat model rather than solved with heavier cryptography the scope doesn't call for

DECISION 02/04 · Storing a CPF

ChosenStore only an HMAC-SHA256 hash of each voter's CPF (keyed by a server-side secret), plus the last two digits for masked display, never the CPF itself

Discarded insteadStore the CPF in clear text with restricted access

The only real need for a CPF after import is matching what a voter types against the roll: a keyed hash does that in a single indexed lookup without ever holding the plaintext value

Cost acceptedThe original CPF is gone for good once hashed. There is no recovery path, and rotating the hash key invalidates matching for every existing list, requiring a re-import. Accepted because no feature in the system ever needs the CPF back in clear form

DECISION 03/04 · Multi-tenant isolation

ChosenRow-level isolation: an organization_id column on every domain table, enforced by a mandatory viewset mixin that filters every query and injects the tenant on every write

Discarded insteadA separate database or schema per church (tenant)

One database and one migration path is operationally simple for the expected scale (hundreds of church tenants rather than thousands), with centralized backup and no per-tenant migration multiplication

Cost acceptedCross-tenant leakage becomes an application bug: the database itself has no structural guard against it. The mixin has to be inherited correctly every time, which is why a consolidated regression test walks every registered viewset and fails if one of them doesn't inherit from it

DECISION 04/04 · Live vote count

ChosenThe organizer's live tally view polls the backend every three seconds, with ETag-based 304 responses so an unchanged count costs almost nothing

Discarded insteadPush the count over WebSockets as votes come in

At the expected scale (a few hundred votes, a few dozen simultaneous organizers per church), three-second polling is imperceptible to a human, and it keeps the whole backend on a plain WSGI process with no extra moving part (no Channels, no async workers, no message broker)

Cost acceptedThere's no true push: the fastest an organizer finds out a ballot round has closed is however long is left on the current three-second cycle. Accepted because nothing in this domain needs sub-second updates

Invariants

  • The votes table never has a column that identifies the voter

    Guaranteed bythe Vote model declares no voter or ballot_session field, and a dedicated schema-introspection test walks information_schema.columns and fails on any column outside the allowed set

  • A voter can never vote twice in the same ballot round

    Guaranteed bya unique constraint on voter_attendance(round_id, voter_id)

  • A request for another church's election data returns 404, never 200

    Guaranteed bya mandatory viewset mixin filters every queryset by the logged-in organization, plus a consolidated regression test that iterates every registered viewset and confirms it inherits from the mixin

  • A CPF is never stored or logged in plaintext

    Guaranteed bythe only persisted value is an HMAC-SHA256 hash keyed by a server-side secret; a mandatory masking helper is the only sanctioned way a CPF can appear near a log line

  • A migration can run twice in a row without changing anything the second time

    Guaranteed byverified in CI: apply all migrations, apply them again, assert the second run reports nothing left to apply

What broke

Symptom
An internal security review found that the ballot_session cookie (the short-lived token linking a voter's identification to their still-unsubmitted ballot) wasn't marked Secure in production, meaning a browser would have been willing to send it over a plain, unencrypted connection if one existed
Root cause
The cookie was configured with the same flags in every environment. Local development necessarily runs over plain HTTP, so the Secure flag had been left off entirely rather than made conditional, and that same unconditional setting carried straight into the production configuration untouched
Fix
Made the Secure flag conditional on the deployment environment (on whenever DEBUG is off), in the same review pass that also removed an undocumented endpoint exposing raw election lookups by ID and added a missing validation check on the tie-resolution flow
Prevention
All three fixes shipped from a single, deliberate audit pass rather than three separate incidents. The review checklist that caught them is now a standing item before any release that touches authentication or session handling

Results

21/21backend tests passingpytest run against the real suite, 2026-09-11, after fixing the one failure: DRF's SessionAuthentication silently downgrades every unauthenticated request from 401 to 403 (knoxis commit b1292b7)
85source files (backend + frontend)count of .py/.tsx/.ts files outside node_modules/venv/migrations, 2026-08-29
10commitsgit log --oneline count on main, 2026-08-29
1day from documentation to a security-reviewed buildall 10 commits are dated the same day
5database migrations, each verified idempotent in CIcount of migration files, backend/*/migrations/

Full stack

Backend

  • Django 5 + Django REST Framework
  • Python 3.12
  • Argon2id (password hashing)

Frontend

  • React 18 + Vite
  • TypeScript

Data

  • PostgreSQL 16

Infra

  • Docker Compose (Nginx + Gunicorn + Postgres)

Interested in a project like this? Get in touch.

Get in touch