Token-Based Attendance System - Preventing Old QR Reuse
Token-Based Attendance System - Preventing Old QR Reuse
Problem
Current system: Old QR codes can be screenshotted and reused later because they’re just URLs with timestamps.
Solution Architecture
Flow:
- QR Code → Contains unique token (not timestamp)
- Student scans → Redirects to validation service
- Backend validates → Token age < 5 minutes?
- If valid → Redirect to Google Form
- If expired → Show error page
Components:
- Frontend (GitHub Pages): Generates QR with tokens
- Backend (Cloudflare Worker): Validates tokens and redirects
- Google Form: Collects attendance data
Implementation Steps
Step 1: Create Cloudflare Worker
Create a free Cloudflare account and deploy this worker:
File: worker.js
// Secret key - CHANGE THIS to a random string!
const SECRET_KEY = 'YOUR_SECRET_KEY_CHANGE_ME_123456789';
const TOKEN_VALIDITY_MS = 5 * 60 * 1000; // 5 minutes
const GOOGLE_FORM_URL = 'https://docs.google.com/forms/d/e/1FAIpQLScCWzzIGI1AFbSLlahBNl18_eWGPChIXNyGkx2ej7joGwnfEQ/viewform';
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const url = new URL(request.url);
// Extract token from URL: /attendance?token=...
const token = url.searchParams.get('token');
if (!token) {
return new Response('Missing token', { status: 400 });
}
try {
// Decode token: timestamp|signature
const [timestamp, signature] = token.split('|');
const timestampMs = parseInt(timestamp);
const now = Date.now();
// Check if token is expired
if (now - timestampMs > TOKEN_VALIDITY_MS) {
return new Response(expiredPage(), {
headers: { 'Content-Type': 'text/html' }
});
}
// Verify signature (prevents tampering)
const expectedSignature = await generateSignature(timestamp);
if (signature !== expectedSignature) {
return new Response('Invalid token', { status: 403 });
}
// Token is valid - redirect to Google Form with session info
const sessionDate = new Date(timestampMs);
const sessionInfo = formatSessionInfo(sessionDate);
const formUrl = `${GOOGLE_FORM_URL}?usp=pp_url&entry.303810813=${encodeURIComponent(sessionInfo)}`;
return Response.redirect(formUrl, 302);
} catch (e) {
return new Response('Invalid token format', { status: 400 });
}
}
async function generateSignature(timestamp) {
const data = timestamp + SECRET_KEY;
const msgBuffer = new TextEncoder().encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex.substring(0, 16); // Use first 16 chars
}
function formatSessionInfo(date) {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = months[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
let hours = date.getHours();
const minutes = date.getMinutes();
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12 || 12;
const minutesStr = minutes.toString().padStart(2, '0');
return `${month} ${day}, ${year} ${hours}:${minutesStr} ${ampm}`;
}
function expiredPage() {
return `
<!DOCTYPE html>
<html>
<head>
<title>QR Code Expired</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
padding: 40px;
border-radius: 15px;
text-align: center;
max-width: 400px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
h1 { color: #e74c3c; font-size: 24px; margin-bottom: 20px; }
p { color: #555; line-height: 1.6; }
.icon { font-size: 64px; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="icon">⏰</div>
<h1>QR Code Expired</h1>
<p>This QR code is no longer valid. Please scan the current QR code displayed by your instructor.</p>
<p style="margin-top: 20px; font-size: 14px; color: #999;">QR codes expire after 5 minutes for security.</p>
</div>
</body>
</html>
`;
}
Deploy command:
npx wrangler deploy
Your worker will be available at: https://your-worker.your-subdomain.workers.dev
Step 2: Update Attendance Page
Replace the QR generation code to create tokens instead of direct Google Form URLs:
File: _pages/attendance-qr.md
---
layout: minimal
title: "Mathematical Statistics I - Attendance"
permalink: /attendance/math-stat-1/
---
<div id="qrcode-container">
<h2>📊 Scan for Attendance</h2>
<div id="qrcode"></div>
<div id="timer">Next refresh in: <span id="countdown">30</span>s</div>
<p class="session-info">
Session: <span id="session-time"></span>
</p>
<div class="instructions">
<strong>Instructions:</strong>
<ul>
<li>Display this page in fullscreen (F11)</li>
<li>Students scan the QR code with phone</li>
<li>QR auto-refreshes every 30 seconds</li>
<li>QR codes expire after 5 minutes</li>
</ul>
</div>
</div>
<script src="/assets/js/qrcode.min.js"></script>
<script>
(function() {
'use strict';
var QR_REFRESH_MS = 30000;
var WORKER_URL = 'https://your-worker.your-subdomain.workers.dev/attendance';
var SECRET_KEY = 'YOUR_SECRET_KEY_CHANGE_ME_123456789'; // Must match worker
var qrcodeContainer = document.getElementById("qrcode");
var sessionTimeDisplay = document.getElementById("session-time");
var countdownDisplay = document.getElementById("countdown");
var countdownInterval;
async function generateSignature(timestamp) {
var data = timestamp + SECRET_KEY;
var msgBuffer = new TextEncoder().encode(data);
var hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) {
return b.toString(16).padStart(2, '0');
}).join('');
return hashHex.substring(0, 16);
}
async function generateQR() {
try {
qrcodeContainer.innerHTML = "";
if (countdownInterval) clearInterval(countdownInterval);
var now = new Date();
var timestamp = now.getTime().toString();
// Display formatted session time
var dateStr = now.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
var timeStr = now.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
sessionTimeDisplay.textContent = dateStr + ' ' + timeStr;
// Generate token: timestamp|signature
var signature = await generateSignature(timestamp);
var token = timestamp + '|' + signature;
// Build URL with token
var attendanceUrl = WORKER_URL + '?token=' + encodeURIComponent(token);
// Generate QR code
new QRCode(qrcodeContainer, {
text: attendanceUrl,
width: 300,
height: 300,
correctLevel: QRCode.CorrectLevel.H
});
// Countdown timer
var secondsLeft = 30;
countdownDisplay.textContent = secondsLeft;
countdownInterval = setInterval(function() {
secondsLeft--;
countdownDisplay.textContent = secondsLeft;
if (secondsLeft <= 0) {
clearInterval(countdownInterval);
}
}, 1000);
} catch (e) {
console.error('QR generation error:', e);
sessionTimeDisplay.textContent = 'Error: ' + e.message;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', generateQR);
} else {
generateQR();
}
setInterval(generateQR, QR_REFRESH_MS);
})();
</script>
How It Works
Token Format:
timestamp|signature
1731744000000|a1b2c3d4e5f6g7h8
Validation:
- Extract timestamp from token
- Check:
current_time - timestamp < 5 minutes? - Verify signature matches (prevents tampering)
- If valid → redirect to Google Form
- If expired → show error page
Security:
- ✅ Time-limited: Expires after 5 minutes
- ✅ Tamper-proof: Signature prevents token modification
- ✅ No replay: Each QR has unique timestamp
- ✅ Screenshots useless: Old tokens rejected by backend
Alternative: Simpler Solutions
If Cloudflare Workers is too complex, here are simpler alternatives:
Option A: Google Apps Script (No External Service)
Use Google Apps Script as backend (free, integrated with Google Forms)
Option B: Accept Detection Strategy
Keep current system but strictly enforce timestamp validation in post-processing
Option C: Physical Attendance
Use a tablet/laptop where students sign in directly (no QR needed)
Deployment Checklist
- Sign up for Cloudflare account (free)
- Create new Worker
- Copy worker.js code
- Change SECRET_KEY to random string
- Deploy worker
- Get worker URL
- Update attendance page with worker URL
- Update SECRET_KEY in attendance page
- Test: Scan QR → Should redirect to form
- Test: Wait 6 minutes, scan same QR → Should show expired page
Would you like me to help set up the Cloudflare Worker, or would you prefer the Google Apps Script solution?
