Skip to content
System Architecture8 min readPublished

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.

Rafay Khan
Rafay Khan

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 ApproachWorks 100% Offline?Compromised Scanner RiskScan Latency
Live API Lookup (UUID)No — fails when Wi-Fi dropsLow180ms – 2,500ms+
Pre-synced Attendee DBYes (if synced before doors)High (full attendee list on device)5ms – 15ms
Symmetric HMAC-SHA256YesCritical (extracted key mints tickets)< 2ms
Asymmetric Ed25519 + Hash ChainYes (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.

Signing and verifying compact QR payloads with Node.js crypto (Ed25519) (typescript)
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.

Append-only SHA-256 hash chain verification on batch sync (typescript)
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.

02 — FAQ

Technical questions

Straight answers to what clients usually want to know before we start.

Why choose Ed25519 over RSA or ECDSA P-256 for QR codes?

Ed25519 produces a compact 64-byte signature and uses a 32-byte public key, keeping the QR code sparse and fast for phone cameras to scan in low light. RSA-2048 signatures are 256 bytes (4x larger), which creates dense QR codes that scan slowly.

Can a user screenshot a valid Ed25519 QR code and share it?

The signature proves the QR code was issued by the server, while replay prevention is handled by the local scan set + LAN peer sync + server reconciliation. For high-security tiers, the attendee app can also wrap the ticket in a 30-second TOTP rotating barcode.

→ — What's next

Got an idea?
Let's ship it.

Tell me what you're building. No sales team, no hand-offs — just the engineer who'll build it.

Rafay KhanRafay KhanYou'll talk to me directly
—in Lahore · UTC+5
Replies in a few hoursUsually around now