Cloud-native distributed voting platform

Cloud-native voting platform built with AWS, containerised services, and distributed architecture.

A cloud-native distributed voting platform designed to explore scalable, secure, and reliable system architecture. The project combines containerised services, AWS infrastructure, and persistent data storage to create a loosely coupled voting system while addressing challenges around data integrity, security, and reliable communication.

Cloud-Native Distributed Voting Platform

Overview

The Distributed Voting Platform is a cloud-based voting system designed to explore how distributed systems and cloud infrastructure can be used to build a secure and auditable digital voting process.

The project combines a REST-based application with AWS services, a DynamoDB-backed data layer, and a blockchain-inspired append-only record structure to protect the integrity of recorded votes.

The system was designed around three core requirements: votes should be recorded reliably, voting data should be difficult to tamper with, and the infrastructure should be able to scale independently as demand changes.

The problem with digital voting

Every voting system has to keep two promises that pull in opposite directions. The first is anonymity: nobody, not the government, not an employer, not a family member, should be able to find out how you voted. That's what stops coercion and vote-buying. If someone can prove how you voted, someone else can pay you or pressure you to vote a certain way.

The second promise is auditability. People need to be able to trust the result, which means the election has to be checkable. Were all the votes counted? Was anything altered? Did anyone vote twice?

Paper ballots solve this with physical process: a sealed booth for anonymity, a locked ballot box and human observers for auditability. Digital systems don't get that for free. The more traceable you make each vote, the easier it is to audit and the harder it is to keep anonymous. Most e-voting systems end up quietly giving up one promise to keep the other.

So I set out to answer one question: can a cloud-native microservices architecture on AWS deliver voter anonymity and cryptographic auditability at the same time?

To test it, I built a working proof of concept: four containerised services designed with Domain-Driven Design, written in C# and Next.js, deployed on Amazon ECS Fargate, with every piece of infrastructure provisioned through Terraform. It isn't a production electoral system, and it was never meant to be. Its purpose was to see whether the right architectural patterns could hold up in a real deployed cloud environment.

What earlier systems taught me

Before designing anything, I looked at what had already been tried, and the field is mostly a list of cautionary tales.

The most famous failure is Voatz, a mobile voting app that was actually used in US federal elections. In 2020, researchers at MIT published an independent security analysis showing that a network attacker could alter, block, or expose a user's vote (Specter et al., 2020). What stuck with me most was the reason. The problems weren't weak encryption. They came from poor architecture, thin threat modelling, and components that knew far too much about each other.

On the other end is Helios, an open-source system from academia that uses homomorphic encryption so anyone can verify an election's tally without seeing individual votes. Helios helped establish the idea of end-to-end verifiability, which is now a benchmark for trustworthy e-voting. It's also mathematically heavy and hard to fit into a standard cloud application.

The idea underneath much of this goes back to David Chaum's 1981 work on untraceable communication: you can prove someone is a legitimate participant without revealing who they are (Chaum, 1981).

Those three ideas shaped my approach. From Voatz I took the lesson that separation of concerns is a security feature, which pointed me toward microservices and DDD, where each bounded context knows as little as possible about the others. From Helios and Chaum I took the goal of verifiability without identity, implemented with tools a cloud developer can reason about.

The life of a single vote

The easiest way to understand the system is to follow one vote from start to finish. At each step, a different service handles the request, and each one sees only what it needs to.

  1. Sign in. The voter signs in with their email through Amazon Cognito and receives a JWT. In testing, a voter account created by Terraform logged in successfully and got a valid token.
  2. Pass the gatekeeper. Every request to a protected route first goes to the Identity Service's /api/auth/verifyendpoint. A valid token gets the voter's ID and role attached as headers. A missing or invalid token gets a 401 before the request reaches any backend service.
  3. Open the ballot. The Ballot Service returns the active elections and, for the chosen one, the full ballot: the title, status, and three candidates with descriptions. These read endpoints are public, and the routing correctly tells them apart from protected ones.
  4. Cast the vote. The voter picks a candidate and the frontend sends a POST /api/votes. The Vote Service builds the vote, writes it with an atomic conditional write, and appends a hash-chained entry to the audit table.
  5. Receive a receipt. The voter gets back a 200 response containing a unique receipt ID, a UUID that points to their entry in the audit ledger.
  6. Try to vote again. A second attempt from the same account for the same election gets a 409 Conflict: "You have already voted in this election." Nothing new appears in either table.
  7. Verify. Anyone with the receipt ID can call GET /api/votes/verify/{receiptId} and get back the election, the candidate, the timestamp, and a verified: true flag. The response never includes the voter's identifier, raw or hashed.
Cloud-Native Distributed Voting Platform
Cloud-Native Distributed Voting Platform Voting Page
Cloud-Native Distributed Voting Platform Successful Vote Page
Cloud-Native Distributed Voting Platform Verified Vote Screen
Cloud-Native Distributed Voting Platform Vote Confirmation Screen
Cloud-Native Distributed Voting Platform Failed Vote Verification Screen

Inside the Vote Service

The Vote Service is where Domain-Driven Design earns its keep. Its domain layer is made of fourteen small, explicit pieces, and each one has a single job.

The two aggregate roots are Vote and AuditEntry. The value objects (VoterId, CandidateId, ElectionId, and Hash) mean a raw string never wanders through the system pretending to be an ID. The domain events, VoteCast and AuditEntryRecorded, are the hooks a future event-driven version would publish. The repository interfaces keep the domain completely ignorant of DynamoDB.

That separation shows up in the application layer too. The CastVoteHandler creates a vote, saves it, appends the audit entry, and returns a receipt, all without knowing anything about HTTP, AWS, or databases. The infrastructure layer plugs in the real DynamoDB implementations underneath. It made the core logic easy to reason about and easy to test in isolation.

Three decisions that did the heavy lifting

1. Hashing the voter before anything is stored

The core privacy mechanism is SHA-256 hashing of the voter's identity. I wrapped it in a Hash value object that computes the hash the moment it's constructed and throws away the raw input. Inside the service, the raw ID only lives in memory for the length of a request. The repository calls Hash.Of() at the persistence boundary, right before the write, so the only version of the voter that ever reaches DynamoDB is the hash.

Because SHA-256 is a one-way function, the voter's hash in the audit table proves that a legitimate voter cast that vote without revealing which one. A voter who knows their own identifier can compute their hash and find their entry, or simply use their receipt ID.

2. Letting the database stop double voting

The obvious way to prevent duplicate votes is to check whether a voter has already voted, then write the vote if they haven't. The problem is the gap between the check and the write. Two requests arriving at nearly the same moment can both pass the check before either one writes, and under enough load you get duplicate votes. This is a classic time-of-check to time-of-use race condition.

Instead, I made the write itself conditional. The vote is saved with a DynamoDB PutItem carrying the condition attribute_not_exists(voterHash). DynamoDB evaluates that condition and performs the write as a single atomic operation. Because the key is the voter's hash, not their raw ID, this check works without the votes table ever storing who the voter is. If that hash already has a record, the database rejects it, and the repository translates that into a DuplicateVoteException, which the API returns as a 409 Conflict. There's no application-level check to get wrong, because there's no check at all.

3. Making the audit log append-only with IAM

If the audit trail lives in an ordinary database, what stops an administrator or a compromised service from editing or deleting records? In this architecture, the answer is IAM. The Vote Service's ECS task role is granted only dynamodb:PutItem and dynamodb:Query on the audit table. UpdateItem and DeleteItem simply aren't there.

So even if an attacker took over the Vote Service's code and tried to rewrite history, AWS would reject the call before it ever reached the database. The immutability isn't a promise in the application code. It's enforced by the infrastructure, and it's written down in Terraform where anyone can review it.

The plan that broke: QLDB, and building my own ledger

My original design used Amazon Quantum Ledger Database (QLDB) for the audit trail. QLDB was purpose-built for this: a managed ledger where every revision is hashed and chained automatically, and the full history can be verified against a single digest. It was the obvious choice.

Then, partway through the build, I found out AWS had deprecated it, with end-of-life set for July 2025. It wasn't available in my development environment at all. The component my audit design depended on was gone.

So I built the chaining myself on DynamoDB. Every time a vote is cast, the audit repository:

  1. Queries the audit table for the latest entry in that election, getting its version number and its hash.
  2. Builds a new entry with the version number incremented by one and a prevHash field set to the previous entry's hash.
  3. Computes the new entry's own entryHash: a SHA-256 of its contents (election ID, voter hash, candidate ID, timestamp, and prevHash).
  4. Generates a UUID receipt ID.
  5. Writes the entry with a condition that the version number hasn't already been used, so two concurrent votes can't claim the same slot in the chain.

Because each entry includes the previous entry's hash inside its own, changing any historical record would break every hash after it. Tampering doesn't just alter one row; it leaves a visible fracture in the chain.

Looking back, I think losing QLDB made the project better. With a managed service, the chaining would have happened inside a black box. Now it's sitting in the codebase where anyone can read it, reason about it, and audit it. For a project whose whole point is verifiability, having the verification mechanism itself be transparent feels right.

It's also a lesson I'll carry into future projects: managed services can disappear, and an architecture is more resilient when its critical guarantees are understood well enough to rebuild.

Where it falls short

I want to be upfront about the limits, because a voting system that oversells its privacy is worse than one that admits its gaps.

Hashing isn't anonymity on its own. SHA-256 is deterministic: the same input always produces the same hash. If an attacker had a list of every eligible voter's identifier, they could hash each one and match the results against the audit table. That would reveal who voted, and because each audit row stores the voter hash next to the candidate, how they voted too. This is called a linkability attack, and my proof of concept is vulnerable to it.

The audit row links voter and choice. That vulnerability comes from a deliberate tradeoff. Keeping the voter hash and the candidate in the same row is what makes individual verification possible: a voter can find their vote and confirm it was recorded correctly. The votes table has a similar exposure, since anyone with read access can see which voters have already voted.

There's a refinement I designed but didn't build. Store the voter hash in one table (proving participation) and the candidate choice in another (supporting the tally), with no shared key linking them. That gives much stronger anonymity, but you lose the ability to verify one specific vote. For this project, I chose individual verifiability.

So, did it answer the question? Within the scope of a proof of concept, yes. Anonymity and auditability aren't mutually exclusive, and the hashed-identity pattern, atomic writes, and an IAM-enforced append-only ledger get meaningfully close to both. But the tension isn't fully resolved, and closing the gap properly takes stronger cryptography than hashing.

What I'd build next

Three directions would take this from a proof of concept toward something production-grade.

Stronger cryptography. Replacing hash-based anonymity with zero-knowledge proofs or homomorphic encryption, the approach Helios takes, would defend against the linkability attack while keeping votes verifiable.

A fully event-driven design. I designed but didn't implement a messaging layer on AWS SNS and SQS. The Vote Service would publish a VoteCast domain event, and separate Audit and Results services would each consume it from their own queue. That decouples the services completely, lets the audit trail be written asynchronously, enables a real-time results view, and lets each concern scale independently. A dedicated Admin service would replace the hardcoded elections, publishing ElectionCreated events of its own.

A public trust anchor. At the close of each election, the system could write a digest of the results to a public Ethereum smart contract. Once it's there, nobody, including whoever runs the election, can quietly change it. That moves verification from "trust the institution" to "check it yourself."

Code Highlights

Cloud-Native Voting Platform POST login endpoint code highlight
Cloud-Native Distributed Voting Platform Voting Page
Cloud-Native Distributed Voting Platform Successful Vote Page
Cloud-Native Distributed Voting Platform Verified Vote Screen
Cloud-Native Distributed Voting Platform Vote Confirmation Screen
Cloud-Native Distributed Voting Platform Failed Vote Verification Screen

Closing thoughts

I started this project expecting the hard part to be the cryptography. It turned out to be the architecture: deciding which service gets to know what, where guarantees should be enforced, and which promises belong to the infrastructure instead of the code.

The biggest lessons were that separation of concerns is a security property, that the strongest checks are the ones that can't be skipped (a value object that always hashes, a database write that can't duplicate, an IAM role that can't delete), and that losing a dependency halfway through can push a design somewhere better.

The proof of concept doesn't solve e-voting, and I don't think any single project could. But it shows that anonymity and auditability don't have to be a binary choice. With careful design, a cloud-native system can get meaningfully close to both, and be honest about the distance that's left.

Let's Connect

Have a question about my Cloud-Native Distributed Voting Platform, or just want to get in touch? I'd love to hear from you.

Jason Kitamirike. All Rights Reserved