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:

  1. Check if token exists and is unused (SELECT)
  2. 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: blockConcurrencyWhile ensures 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:

  1. Check election state (open/closed)
  2. Use blockConcurrencyWhile for atomicity
  3. Check token in Durable Object cache
  4. If not cached, check D1 database
  5. If unused, mark as used atomically
  6. 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:

  1. Check Durable Object cache (1-minute TTL)
  2. If not cached, fetch from D1
  3. Calculate if election is open (now >= open_at && now < close_at)
  4. 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:

  1. Generate UUID tokens
  2. Hash tokens using SHA-256
  3. Store hashes in tokens table
  4. Store plaintext in invites table (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:

  1. Hash the provided token
  2. Call TokenManager Durable Object
  3. Durable Object validates atomically:
    • Check election is open
    • Check token exists
    • Check token is unused
    • Mark as used (if valid)
  4. 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_at timestamp 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 tokens table
  • Plaintext: Only in invites table (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

  1. Election State Caching: Reduces D1 queries
  2. Token State Caching: Fast lookup for used tokens
  3. Batch Operations: Not needed (single token per request)
  4. 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

  1. Token Validation Latency: Should be < 10ms
  2. Cache Hit Rate: Should be > 80%
  3. Race Condition Attempts: Should be rare
  4. 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:

  1. Voter already voted
  2. Race condition (another request used it)
  3. Token was used in a previous election

Solutions:

  1. Check audit logs for previous vote
  2. Normal behavior (prevents double-voting)
  3. Verify election ID matches

Slow Token Validation

Possible causes:

  1. Durable Object cold start
  2. Cache miss (fetching from D1)
  3. High concurrency

Solutions:

  1. Normal for first request
  2. Subsequent requests use cache
  3. Consider increasing cache TTL

See Also