Overview
Token management ensures secure, one-person-one-vote elections by using Cloudflare Durable Objects to atomically validate and mark tokens as used. This prevents race conditions and double-voting even under high concurrency.
Why Durable Objects?
The Problem
Without Durable Objects, token validation requires:
- Check if token exists and is unused (SELECT)
- Mark token as used (UPDATE)
Between steps 1 and 2, another request could validate the same token, leading to double-voting.
The Solution
Durable Objects provide:
- Atomic Operations: Validation and marking happen atomically
- Strong Consistency: Single source of truth per election
- Race Condition Prevention:
blockConcurrencyWhileensures only one operation at a time - Caching: Election state cached for performance
TokenManager Durable Object
Architecture
Each election has a dedicated TokenManager Durable Object instance that handles all token validations for that election.
ID Format:
const id = env.TOKEN_MANAGER.idFromName(`election:${electionId}`);This ensures one Durable Object per election.
Key Functions
1. Validate and Mark Used
Action: validateAndMarkUsed
Process:
- Check election state (open/closed)
- Use
blockConcurrencyWhilefor atomicity - Check token in Durable Object cache
- If not cached, check D1 database
- If unused, mark as used atomically
- Cache the used state
Response:
{
valid: boolean; // Token is valid and was unused
alreadyUsed: boolean; // Token was already used
electionOpen: boolean; // Election is currently open
}Atomicity:
return await this.state.blockConcurrencyWhile(async () => {
// All operations here are atomic
// Only one request can execute at a time
});2. Get Election State
Action: getElectionState
Process:
- Check Durable Object cache (1-minute TTL)
- If not cached, fetch from D1
- Calculate if election is open (now >= open_at && now < close_at)
- Cache for 1 minute
Response:
{
open: boolean;
openAt: number;
closeAt: number;
}Caching:
- Cache TTL: 60 seconds
- Automatically expires
- Reduces D1 queries
3. Invalidate Cache
Action: invalidateElectionCache
When to Use:
- Election times change
- Need to force refresh
Process:
- Deletes cached election state
- Next request will fetch fresh from D1
Token Lifecycle
1. Generation
When: Admin generates tokens for an election
Process:
- Generate UUID tokens
- Hash tokens using SHA-256
- Store hashes in
tokenstable - Store plaintext in
invitestable (for email)
Storage:
tokens: {
token_hash: string, -- SHA-256 hash
election_id: string,
used_at: number | null
}
invites: {
token_plaintext: string, -- For email sending
token_hash: string
}2. Distribution
Methods:
- Individual email invites
- Batch email invites (magic links)
- Admin token management
Security:
- Plaintext tokens only in:
- Email invites
- “My Elections” page
- Admin UI (for resending)
3. Validation
When: Voter attempts to cast a vote
Process:
- Hash the provided token
- Call TokenManager Durable Object
- Durable Object validates atomically:
- Check election is open
- Check token exists
- Check token is unused
- Mark as used (if valid)
- Return validation result
Code Flow:
// Hash token
const tokenHash = hashToken(token);
// Get Durable Object
const id = env.TOKEN_MANAGER.idFromName(`election:${electionId}`);
const stub = env.TOKEN_MANAGER.get(id);
// Validate atomically
const result = await stub.fetch(new Request("...", {
body: JSON.stringify({
action: "validateAndMarkUsed",
tokenHash,
electionId
})
}));4. Usage
After Validation:
- Token marked as used (
used_attimestamp set) - Cannot be used again
- Vote is recorded
Token Hashing
Algorithm
SHA-256 hashing is used for security:
function hashToken(token: string): string {
const encoder = new TextEncoder();
const data = encoder.encode(token);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}Why Hash?
- Security: Plaintext tokens not stored in database
- Privacy: Even database admins can’t see tokens
- Compliance: Meets security best practices
Storage
- Hashed: Stored in
tokenstable - Plaintext: Only in
invitestable (for email) - Never: In logs or error messages
Caching Strategy
Election State Cache
Location: Durable Object storage
Key: election:${electionId}
TTL: 60 seconds
Purpose: Reduce D1 queries for election state checks
Invalidation:
- Automatic (TTL expiration)
- Manual (when election times change)
Token State Cache
Location: Durable Object storage
Key: token:${tokenHash}
TTL: None (persists until election ends)
Purpose:
- Fast lookup for already-used tokens
- Reduce D1 queries
Storage:
{
used: boolean;
usedAt?: number;
}Race Condition Prevention
The Problem
Scenario: Two voters submit votes simultaneously with the same token
Without Durable Objects:
Request 1: SELECT token (unused) ✓
Request 2: SELECT token (unused) ✓ // Both see it as unused!
Request 1: UPDATE token (mark used) ✓
Request 2: UPDATE token (mark used) ✓ // Both votes recorded!
Result: Double-voting! ❌
The Solution
With Durable Objects:
Request 1: blockConcurrencyWhile { SELECT + UPDATE } ✓
Request 2: blockConcurrencyWhile { waits... } ⏳
Request 1: Complete, token marked used ✓
Request 2: blockConcurrencyWhile { SELECT (already used) } ✗
Result: Only one vote recorded! ✓
Implementation
return await this.state.blockConcurrencyWhile(async () => {
// Check token
const token = await db.prepare("SELECT used_at FROM tokens WHERE ...").first();
if (token.used_at !== null) {
return { valid: false, alreadyUsed: true };
}
// Mark as used (atomic)
const result = await db.prepare(
"UPDATE tokens SET used_at = ? WHERE ... AND used_at IS NULL"
).run();
if (result.meta.changes > 0) {
return { valid: true, alreadyUsed: false };
} else {
// Race condition: another request used it
return { valid: false, alreadyUsed: true };
}
});Performance Considerations
Durable Object Limits
- Concurrent Requests: Handled sequentially per object
- Storage: 128KB per object (sufficient for caching)
- Latency: ~1-2ms per operation
Optimization Strategies
- Election State Caching: Reduces D1 queries
- Token State Caching: Fast lookup for used tokens
- Batch Operations: Not needed (single token per request)
- Connection Pooling: Handled by Cloudflare
Scalability
- Per Election: One Durable Object handles all tokens
- Concurrent Votes: Sequential processing prevents races
- Large Elections: Performance scales linearly
Error Handling
Invalid Token
Response:
{
"valid": false,
"alreadyUsed": false,
"electionOpen": true
}Causes:
- Token doesn’t exist
- Token hash mismatch
- Wrong election
Already Used Token
Response:
{
"valid": false,
"alreadyUsed": true,
"electionOpen": true
}Causes:
- Token was already used
- Race condition (another request used it first)
Election Closed
Response:
{
"valid": false,
"alreadyUsed": false,
"electionOpen": false
}Causes:
- Election hasn’t opened yet
- Election has closed
Configuration
Wrangler Configuration
{
"durable_objects": {
"bindings": [
{
"name": "TOKEN_MANAGER",
"class_name": "TokenManager",
"script_name": "ihnyc-rc-vote"
}
]
}
}Migration
Durable Objects are automatically created when first accessed. No migration needed.
Monitoring
Metrics to Watch
- Token Validation Latency: Should be < 10ms
- Cache Hit Rate: Should be > 80%
- Race Condition Attempts: Should be rare
- Durable Object Errors: Should be zero
Logging
Token validation is logged via audit logs:
- Action:
vote.submitted - Metadata: Election ID, timestamp
- No token information (security)
Security Considerations
Token Security
- ✅ Tokens hashed before storage
- ✅ Plaintext only in emails
- ✅ Atomic validation prevents double-voting
- ✅ Election state validated
- ✅ No token exposure in logs
Durable Object Security
- ✅ One object per election (isolation)
- ✅ Atomic operations (consistency)
- ✅ No shared state (security)
Troubleshooting
”Token already used” Error
Possible causes:
- Voter already voted
- Race condition (another request used it)
- Token was used in a previous election
Solutions:
- Check audit logs for previous vote
- Normal behavior (prevents double-voting)
- Verify election ID matches
Slow Token Validation
Possible causes:
- Durable Object cold start
- Cache miss (fetching from D1)
- High concurrency
Solutions:
- Normal for first request
- Subsequent requests use cache
- Consider increasing cache TTL
See Also
- voting-system - How votes are cast
- results-calculation - How results are computed
- ops - General infrastructure documentation