How to Build an Offline-First Cryptographic QR Ticket Validator with Ed25519, Hash Chaining & Fastify
When I was brought on as Lead Engineer for NodusTickets, the core requirement was deceptively simple: verify event tickets at high-volume entry gates where internet connectivity drops to zero, while making it mathematically impossible to forge a QR code or tamper with local scan logs.
Full-Stack Product Engineer · Full-Stack Engineer @ Callture
Key architectural takeaways (TL;DR)
- Venue Wi-Fi and cellular networks fail under crowd load; relying on a live database lookup at the turnstile causes entry bottlenecks or forces staff to wave unverified screenshots through.
- Using asymmetric Ed25519 signatures lets every scanner device verify ticket authenticity in under 2ms completely offline using only a bundled 32-byte public key — without exposing any signing secret on the device.
- To prevent local database tampering on gate devices while offline, every scan record is appended to a SHA-256 hash chain (`prev_hash + scan_payload`) before syncing to the Fastify backend.
1. Why Database-Lookup & HMAC QR Tickets Fail at the Gate
Most ticketing apps store a UUID inside a QR code and fire a `POST /api/tickets/verify` request when a gate attendant scans it. That works in a quiet office and collapses when 3,000 attendees arrive in a 20-minute window and saturate the nearest cell tower.
The common workaround is caching a list of valid UUIDs or using a shared HMAC secret (`HMAC-SHA256(ticket_id, secret)`) on the scanner app. Both introduce serious security flaws: pre-syncing the full attendee list leaks PII to every borrowed scanner phone, and embedding a symmetric HMAC secret in a mobile or web client means extracting the secret from one device lets an attacker mint unlimited valid tickets.
| Validation Approach | Works 100% Offline? | Compromised Scanner Risk | Scan Latency |
|---|---|---|---|
| Live API Lookup (UUID) | No — fails when Wi-Fi drops | Low | 180ms – 2,500ms+ |
| Pre-synced Attendee DB | Yes (if synced before doors) | High (full attendee list on device) | 5ms – 15ms |
| Symmetric HMAC-SHA256 | Yes | Critical (extracted key mints tickets) | < 2ms |
| Asymmetric Ed25519 + Hash Chain | Yes (zero network required) | Minimal (public key is read-only) | < 2ms |
2. Designing a Compact Ed25519 Signed QR Payload
With Ed25519 (Curve25519), the Fastify backend holds the private signing key in KMS/environment memory, while scanner devices only store the 32-byte public key. Even if an attacker reverse-engineers a gate device, they can only verify tickets — never sign new ones.
Camera autofocus speed depends directly on QR module density. Instead of verbose JSON or JWTs, we pack the ticket into a compact versioned payload (`version . eventId . ticketId . tier . issuedAt`) and append the 64-byte Ed25519 signature encoded in Base64url.
import { sign, verify, createPrivateKey, createPublicKey } from "node:crypto";
export interface TicketPayload {
v: 1;
evt: string; // Event short ID
tkt: string; // Unique ticket ID
tier: number;
exp: number; // Unix timestamp (seconds)
}
export function issueSignedQrToken(payload: TicketPayload, privateKeyPem: string): string {
const canonical = `v1|${payload.evt}|${payload.tkt}|${payload.tier}|${payload.exp}`;
const data = Buffer.from(canonical, "utf8");
const signature = sign(null, data, createPrivateKey(privateKeyPem));
return `${canonical}.${signature.toString("base64url")}`;
}
export function verifyQrTokenOffline(token: string, publicKeyPem: string): TicketPayload | null {
const dot = token.lastIndexOf(".");
if (dot === -1) return null;
const canonical = token.slice(0, dot);
const sig = Buffer.from(token.slice(dot + 1), "base64url");
const isValid = verify(null, Buffer.from(canonical, "utf8"), createPublicKey(publicKeyPem), sig);
if (!isValid) return null;
const [, evt, tkt, tier, exp] = canonical.split("|");
if (Date.now() / 1000 > Number(exp)) return null;
return { v: 1, evt, tkt, tier: Number(tier), exp: Number(exp) };
}3. Stopping Local Audit Log Tampering with SHA-256 Hash Chaining
Cryptographic signatures prove a ticket is genuine, but what stops someone with access to an offline scanner from deleting a scan row in local storage to let a friend reuse the same QR code, or injecting fake check-ins?
We solved this by turning the scanner's local check-in log into an append-only hash chain. When a shift starts, the scanner receives a signed `genesis_hash` from the server. Every subsequent scan computes `entry_hash = SHA256(prev_hash + ticketId + scannedAt + deviceId)`. When the device reconnects and uploads its batch to Fastify, the server replays the chain: if a single record was deleted, reordered, or edited offline, the chain breaks at that exact sequence number.
import { createHash } from "node:crypto";
export interface ScanRecord {
seq: number;
ticketId: string;
deviceId: string;
scannedAt: string;
prevHash: string;
entryHash: string;
}
export function computeEntryHash(rec: Omit<ScanRecord, "entryHash">): string {
return createHash("sha256")
.update(`${rec.seq}|${rec.prevHash}|${rec.ticketId}|${rec.deviceId}|${rec.scannedAt}`)
.digest("hex");
}
export function verifyAuditChain(records: ScanRecord[], genesisHash: string): { ok: boolean; brokenAt?: number } {
let expectedPrev = genesisHash;
for (const rec of records) {
if (rec.prevHash !== expectedPrev || computeEntryHash(rec) !== rec.entryHash) {
return { ok: false, brokenAt: rec.seq };
}
expectedPrev = rec.entryHash;
}
return { ok: true };
}4. Handling Multi-Gate Replay & Reconnection Reconciliation
If a venue has four entry gates operating offline simultaneously, how do you stop two people from scanning the exact same valid QR code at Gate A and Gate B two minutes apart?
First, scanners on the same local Wi-Fi LAN broadcast compact bloom-filter/ticket-ID gossip packets over WebSockets/UDP even when the upstream WAN internet is dead. Second, when WAN connectivity resumes, the Fastify backend ingests all device hash chains idempotently (`ON CONFLICT (event_id, ticket_id) DO NOTHING`), flags any duplicate cross-gate timestamps with the exact gate IDs, and pushes delta revocation lists to every active scanner.
- Sub-2ms cryptographic verification on mid-range Android and iOS devices.
- Zero PII stored on gate scanners — only the 32-byte Ed25519 public key and revoked ticket ID hashes.
- Complete mathematical auditability for event organizers and financial reconciliation.