Each ballot uses a token scoped to one election. The plaintext token reaches the voter; its SHA-256 hash is the key stored in the tokens table.
Data model
| Field | Meaning |
|---|---|
token_hash | SHA-256 hex digest and primary key |
election_id | Election that accepts the token |
batch_name | Optional administrative grouping |
created_at | Creation time in milliseconds |
used_at | First accepted-use time, or NULL |
Ballots do not store a token hash or email address. Invitation records do: invites.token_hash connects delivery state to a token, and invites.token_plaintext supports resending the same voting link. An administrator can therefore determine whether an invite’s token was used, but cannot derive that token’s ballot choice from the ballot table.
Generate tokens
POST /admin/elections/:id/tokens
Content-Type: application/json{
"count": 25,
"batch_name": "July mailing"
}| Input | Contract |
|---|---|
count | Required; numeric comparisons enforce 1 through 1000 |
batch_name | Optional; at most 200 characters |
Generation is rejected after an election closes. The Worker creates UUID tokens, hashes them with SHA-256, inserts the hashes with one D1 batch operation, and returns the plaintext values in the response.
{
"tokens": ["7db86f57-7e4c-49e8-98fe-ecb8f91c6887"],
"batch_name": "July mailing",
"count": 1,
"warning": "Tokens shown once. Store securely."
}Standalone generation does not persist plaintext in tokens. Email invitation flows separately persist plaintext in invites so an invitation can be resent.
Validate and consume a token
sequenceDiagram participant Client participant Worker participant DO as TokenManager participant D1 Client->>Worker: ballot + plaintext token Worker->>Worker: SHA-256(token) Worker->>DO: validateAndMarkUsed(hash, election) DO->>D1: read election timing DO->>D1: UPDATE tokens SET used_at = now WHERE used_at IS NULL D1-->>DO: changed row count DO-->>Worker: valid / alreadyUsed / electionOpen Worker->>D1: insert ballot
The Worker names one Durable Object instance per election: token-manager-{election_id}. Inside that object, blockConcurrencyWhile serializes token consumption. The D1 update also includes used_at IS NULL, so a competing request cannot change the same unused row twice.
The object returns:
| Field | Meaning |
|---|---|
valid | The conditional D1 update consumed the token |
alreadyUsed | D1 or Durable Object storage already recorded use |
electionOpen | Current time is within open_at <= now < close_at |
The route maps those states to 401 Invalid token or 403 Token already used / 403 Election is not open for voting.
The voting page performs a direct D1 lookup before displaying a ballot. Submission still repeats authoritative validation through the Durable Object.
Durable Object storage
The object stores used-token state under token:{hash}. It also writes election timing under election:{id} with a 60-second TTL.
The current read guard only returns a cached election state when the current time is within 60 seconds of the cached closeAt; otherwise it reads D1 again. The object exposes invalidateElectionCache, but no current admin route calls it after changing election dates.
Batch statistics and deletion
GET /admin/elections/:id/tokens groups tokens by batch_name and returns counts for total, used, and unused tokens. Named batches also include distinct invitation email addresses joined through invites.token_hash.
DELETE /admin/elections/:id/batches accepts:
{
"batch_name": "July mailing"
}Use null for the unnamed batch. The route deletes every token in the selected batch, including used tokens, and reports the counts removed. The current invites schema has no foreign key from token_hash to tokens, so invitation records are not cascade-deleted with the batch.
Current constraints
- Token consumption and ballot insertion are separate D1 operations. If ballot insertion fails after
used_atis written, the token remains consumed without a ballot. - Token generation does not explicitly require
countto be an integer or number; fractional and coercible values can pass the current validator. - Plaintext token recovery is intentionally absent for standalone token batches, but invitation flows retain plaintext in the admin-accessible
invitestable. - SHA-256 hashing protects tokens at rest from direct disclosure; it does not make a leaked plaintext voting link safe.
- Deleting a batch does not check whether it contains accepted votes.
- Election date and manual-close routes do not call the Durable Object’s cache-invalidation action.
Sources
Verified against ihnyc-rc-vote commit 2451aa1.
src/durable-objects/TokenManager.tssrc/models/token.tssrc/routes/admin.tssrc/routes/vote.tssrc/utils/db.tsmigrations/001_initial_schema.sqlmigrations/004_allow_null_token_hash.sqlmigrations/005_add_token_plaintext.sql