Skip to main content
GatePass uses cryptographic QR codes and blockchain verification to prevent ticket fraud and streamline event check-in.

Overview

Ticket verification in GatePass combines QR code scanning with blockchain-based ownership verification to create a secure, fraud-resistant check-in system that works both online and offline.

Key Features

QR Code Scanning

Fast, contactless check-in using mobile device cameras

Cryptographic Security

Tickets are signed with cryptographic signatures to prevent forgery

Blockchain Verification

On-chain ownership verification ensures ticket authenticity

Offline Mode

Queue scans offline and sync when connection is restored

Check-In Model

Check-ins are recorded in the database with detailed metadata:
schema.prisma
model CheckIn {
  id        String   @id @default(cuid())
  
  checkedInAt DateTime @default(now())
  checkedInBy String? // Staff member who checked in
  location    String? // Check-in location/gate
  
  // POA NFT info
  poaTokenId      Int?
  poaContractAddr String?
  poaTxHash       String?

  // Relations
  ticket   Ticket @relation(fields: [ticketId], references: [id])
  ticketId String @unique
  
  event    Event  @relation(fields: [eventId], references: [id])
  eventId  String
  
  user     User   @relation(fields: [userId], references: [id])
  userId   String

  @@map("check_ins")
}

QR Code Structure

GatePass QR codes contain cryptographically signed ticket data:
// QR Code Format
// ticketId|eventId|timestamp|signature

// Example:
// TKT-123|EVT-456|1704067200|0x8a7b9c2d...

QR Code Generation

security.ts
import crypto from 'crypto'

export const DEFAULT_QR_SECRET_SALT = 'gatepass-secure-qr-salt-2024'

export function generateQr(
  ticketId: string,
  eventId: string,
  secretSalt: string = DEFAULT_QR_SECRET_SALT
): string {
  const timestamp = Date.now()
  const payload = `${ticketId}|${eventId}|${timestamp}`
  
  // Create HMAC signature
  const signature = crypto
    .createHmac('sha256', secretSalt)
    .update(payload)
    .digest('hex')
  
  return `${payload}|${signature}`
}

QR Code Verification

security.ts
export interface QrVerificationResult {
  status: 'VALID' | 'ALREADY_USED' | 'FAKE' | 'TOO_EARLY' | 'EXPIRED'
  ticketId: string
  eventId: string
  timestamp: number
}

export async function verifyQr(
  qrString: string,
  secretSalt: string,
  expectedEventId: string,
  isUsed: (ticketId: string) => boolean,
  markUsed: (ticketId: string) => void,
  eventStartTime: number
): Promise<QrVerificationResult> {
  const parts = qrString.split('|')
  
  if (parts.length !== 4) {
    return {
      status: 'FAKE',
      ticketId: '',
      eventId: '',
      timestamp: 0
    }
  }
  
  const [ticketId, eventId, timestampStr, providedSignature] = parts
  const timestamp = parseInt(timestampStr, 10)
  
  // Verify event ID
  if (eventId !== expectedEventId) {
    return { status: 'FAKE', ticketId, eventId, timestamp }
  }
  
  // Recreate signature
  const payload = `${ticketId}|${eventId}|${timestamp}`
  const expectedSignature = crypto
    .createHmac('sha256', secretSalt)
    .update(payload)
    .digest('hex')
  
  // Compare signatures (constant-time comparison)
  if (!crypto.timingSafeEqual(
    Buffer.from(providedSignature, 'hex'),
    Buffer.from(expectedSignature, 'hex')
  )) {
    return { status: 'FAKE', ticketId, eventId, timestamp }
  }
  
  // Check if ticket already used
  if (isUsed(ticketId)) {
    return { status: 'ALREADY_USED', ticketId, eventId, timestamp }
  }
  
  // Check timing
  const now = Date.now()
  if (now < eventStartTime) {
    return { status: 'TOO_EARLY', ticketId, eventId, timestamp }
  }
  
  // QR code expiry (24 hours after event start)
  const qrExpiryTime = eventStartTime + (24 * 60 * 60 * 1000)
  if (now > qrExpiryTime) {
    return { status: 'EXPIRED', ticketId, eventId, timestamp }
  }
  
  // Mark as used
  markUsed(ticketId)
  
  return { status: 'VALID', ticketId, eventId, timestamp }
}

Smart Contract Check-In

Tickets are marked as used on-chain during check-in:
/**
 * @notice Check-in a ticket and mint POA
 * @param tokenId The ticket token ID to check in
 */
function checkIn(uint256 tokenId) external onlyEventStarted {
    if (ownerOf(tokenId) != msg.sender) {
        revert NotTokenOwner();
    }
    
    if (ticketUsed[tokenId]) {
        revert TicketAlreadyUsed();
    }

    ticketUsed[tokenId] = true;

    // Mint Proof of Attendance NFT
    uint256 poaTokenId = proofOfAttendance.mintPOA(msg.sender, tokenId);
    
    emit TicketCheckedIn(tokenId, msg.sender);
    emit ProofOfAttendanceMinted(msg.sender, tokenId, poaTokenId);
}

Mobile Scanner Interface

GatePass provides a mobile-optimized scanner interface for event staff:
1

Select Event

Staff member selects the event they’re checking in attendees for
2

Start Scanning

Camera activates and begins scanning for QR codes
3

Automatic Verification

QR code is automatically verified when detected
4

Visual Feedback

Green checkmark for valid tickets, red X for invalid/used tickets
5

POA Minting

Proof of Attendance NFT is automatically minted on successful check-in

Scanner Component

MobileScanner.tsx
export function MobileScanner({ onBack }: MobileScannerProps) {
  const [isScanning, setIsScanning] = useState(false)
  const [scanResults, setScanResults] = useState<ScanResult[]>([])
  const [currentEventId, setCurrentEventId] = useState<string>('1')
  const usedTicketsRef = useRef<Set<string>>(new Set())
  const scannerRef = useRef<Html5QrcodeScanner | null>(null)

  useEffect(() => {
    const containerId = 'qr-reader'
    if (isScanning && !scannerRef.current) {
      const scanner = new Html5QrcodeScanner(
        containerId, 
        { fps: 10, qrbox: 250 }, 
        false
      )
      
      scanner.render(
        async (decodedText) => {
          const parts = decodedText.split('|')
          const eventIdFromQr = parts[1] || currentEventId
          
          const result = await verifyQr(
            decodedText,
            DEFAULT_QR_SECRET_SALT,
            eventIdFromQr,
            (tid) => usedTicketsRef.current.has(tid),
            (tid) => usedTicketsRef.current.add(tid),
            Date.now() - 60 * 60 * 1000
          )
          
          handleVerificationOutcome(decodedText, result)
        },
        (error) => {
          // Ignore scan errors (noisy)
        }
      )
      
      scannerRef.current = scanner
    }
  }, [isScanning, currentEventId])

  const handleVerificationOutcome = (qrString: string, result: any) => {
    const statusMap: Record<string, ScanResult['status']> = {
      VALID: 'valid',
      ALREADY_USED: 'used',
      FAKE: 'invalid',
      TOO_EARLY: 'expired',
      EXPIRED: 'expired'
    }
    const status = statusMap[result.status] ?? 'invalid'

    const newScan: ScanResult = {
      id: Date.now().toString(),
      ticketId: result.ticketId,
      eventTitle: selectedEventTitle,
      attendeeName: 'Unknown',
      ticketType: 'General',
      seatNumber: 'N/A',
      status,
      scanTime: new Date().toLocaleTimeString(),
      walletAddress: '0x0000...0000'
    }

    setScanResults(prev => [newScan, ...prev])

    if (status === 'valid') {
      toast.success('VALID ticket')
    } else if (status === 'used') {
      toast.error('ALREADY USED')
    } else {
      toast.error('FAKE/INVALID ticket')
    }
  }
}

Offline Check-In

The scanner supports offline operation for venues with poor connectivity:
Before the event, staff can download a local database of all valid ticket IDs and signatures for offline verification.
const preDownloadDb = async (eventId: string) => {
  const tickets = await fetch(`/api/events/${eventId}/tickets`)
    .then(res => res.json())
  
  // Store in IndexedDB or localStorage
  await localDB.store('tickets', tickets)
  
  toast.success('Ticket database downloaded for offline use')
}
When offline, the scanner uses local cryptographic verification without network calls.
const verifyOffline = async (qrString: string) => {
  // Verify signature locally
  const isValid = verifyQrSignature(qrString)
  
  if (!isValid) {
    return { status: 'FAKE' }
  }
  
  // Check against local database
  const ticket = await localDB.getTicket(ticketId)
  
  if (ticket?.used) {
    return { status: 'ALREADY_USED' }
  }
  
  // Mark as used locally
  await localDB.markUsed(ticketId)
  
  return { status: 'VALID' }
}
Offline scans are queued and synced when connection is restored.
const syncOfflineScans = async () => {
  const queuedScans = await localDB.getQueuedScans()
  
  for (const scan of queuedScans) {
    try {
      await fetch('/api/check-ins', {
        method: 'POST',
        body: JSON.stringify(scan)
      })
      
      await localDB.removeFromQueue(scan.id)
    } catch (error) {
      // Will retry on next sync
      console.error('Sync failed:', error)
    }
  }
  
  toast.success(`Synced ${queuedScans.length} offline scans`)
}

Security Features

Cryptographic Signatures

HMAC-SHA256 signatures prevent QR code forgery and tampering

Blockchain Verification

On-chain ownership verification ensures tickets haven’t been transferred or revoked

Anti-Replay Protection

Tickets are marked as used in the smart contract to prevent double-entry

Time-Based Validation

QR codes are only valid during the event window to prevent early or late scanning

Event-Specific Codes

QR codes are bound to specific events and cannot be reused for other events

Constant-Time Comparison

Signature verification uses timing-safe comparison to prevent timing attacks

Check-In Methods

1. Self Check-In

Attendees scan their own QR code at entrance kiosks:
// Attendee calls checkIn() on the smart contract
const checkIn = async (ticketId: number) => {
  const contract = new ethers.Contract(
    eventContractAddress,
    EventTicketABI,
    signer
  )
  
  const tx = await contract.checkIn(ticketId)
  await tx.wait()
  
  toast.success('Checked in successfully! POA NFT minted.')
}

2. Staff-Assisted Check-In

Event staff scan attendee QR codes:
// Staff member scans QR code with mobile scanner
// Backend calls organizerCheckIn() on behalf of attendee
const staffCheckIn = async (ticketId: number, attendeeAddress: string) => {
  const contract = new ethers.Contract(
    eventContractAddress,
    EventTicketABI,
    organizerSigner
  )
  
  const tx = await contract.organizerCheckIn(ticketId)
  await tx.wait()
  
  // Record in database
  await prisma.checkIn.create({
    data: {
      ticketId: ticket.id,
      eventId: event.id,
      userId: attendee.id,
      checkedInBy: staff.id,
      location: 'Main Entrance',
      checkedInAt: new Date()
    }
  })
}

3. Manual Verification

For backup when QR scanning fails:
const manualCheckIn = async (ticketId: string) => {
  // Look up ticket in database
  const ticket = await prisma.ticket.findFirst({
    where: { id: ticketId },
    include: { order: true, event: true }
  })
  
  if (!ticket) {
    throw new Error('Ticket not found')
  }
  
  if (ticket.isUsed) {
    throw new Error('Ticket already used')
  }
  
  // Verify payment completed
  if (ticket.order.paymentStatus !== 'COMPLETED') {
    throw new Error('Payment not completed')
  }
  
  // Mark as used
  await prisma.ticket.update({
    where: { id: ticketId },
    data: { isUsed: true, usedAt: new Date() }
  })
  
  // Create check-in record
  await prisma.checkIn.create({
    data: {
      ticketId: ticket.id,
      eventId: ticket.eventId,
      userId: ticket.order.userId,
      checkedInBy: staff.id,
      location: 'Manual Verification'
    }
  })
}

Scanner Analytics

Track check-in performance and identify bottlenecks:
interface ScannerStats {
  totalScanned: number
  validTickets: number
  invalidTickets: number
  alreadyUsed: number
  checkInRate: number // Percentage of sold tickets checked in
  avgScanTime: number // Average time per scan in milliseconds
  peakHours: { hour: number; count: number }[]
}

const getCheckInStats = async (eventId: string) => {
  const checkIns = await prisma.checkIn.findMany({
    where: { eventId },
    include: { ticket: true }
  })
  
  const totalTicketsSold = await prisma.ticket.count({
    where: { eventId }
  })
  
  return {
    totalScanned: checkIns.length,
    validTickets: checkIns.filter(c => c.ticket.isUsed).length,
    checkInRate: (checkIns.length / totalTicketsSold) * 100,
    peakHours: calculatePeakHours(checkIns)
  }
}

Best Practices

Always test your check-in system before the event with a small group to identify any issues.
1

Pre-Event Testing

Test the scanner with sample tickets 24-48 hours before the event
2

Download Offline Database

Pre-download the ticket database to all scanner devices before the event
3

Multiple Check-In Points

Set up multiple check-in stations to reduce queues and wait times
4

Backup Power

Ensure all scanner devices have backup power sources
5

Staff Training

Train staff on how to use the scanner and handle edge cases
6

Manual Verification Process

Have a documented manual verification process for when technology fails

Troubleshooting

  • Ensure adequate lighting
  • Clean camera lens
  • Adjust distance between camera and QR code
  • Use manual entry as backup
  • Check if attendee already checked in
  • Verify ticket hasn’t been transferred
  • Look up check-in record in database
  • Contact event organizer if fraudulent activity suspected
  • Switch to offline mode
  • Use local verification
  • Queue scans for later sync
  • Ensure offline database is up to date
  • Verify QR code hasn’t been tampered with
  • Check event ID matches current event
  • Ensure secret salt is correctly configured
  • Use blockchain verification as fallback

Next Steps

Proof of Attendance

Learn about POA NFTs that are automatically minted during check-in

Event Management

Manage your events and monitor check-in rates

Build docs developers (and LLMs) love