A QR ticket is a bearer token that walks
A signature on a ticket proves it was issued, not that it is unused. Replay protection is a state problem, and it lives in one atomic write at the moment of admission.
A QR code on a phone screen is a bearer credential. Whoever presents it gets what it grants, and there is no channel binding, no device it is tied to, nothing that distinguishes the original from a screenshot. Copy the pixels and you have copied the ticket. That is not a flaw in QR codes; it is what a printable credential is. When I built the QR ticketing and contactless entry system for visitor attractions, most of the security discussion was about how to sign the ticket, and almost none of it was about the only question that decides whether a gate can be fooled: where does the spend happen.
What a signature proves
Signing is the easy half, and it is worth doing well. A ticket token can be small: an identifier, an expiry, and an Ed25519 signature, which RFC 8032 fixes at 64 bytes. Put a 16-byte id and an 8-byte expiry next to it and the whole token is 88 bytes before encoding. QR codes have room to spare. The ISO/IEC 18004 capacity tables, reproduced on most QR reference sites, give a version 40 symbol at the lowest error correction level 2,953 bytes, and a version 5 symbol, which scans comfortably from a phone screen at arm's length, over a hundred. The token fits in a small, dense, fast-scanning code with capacity left for a stronger error correction level, which matters more at a gate than payload does.
Here is what that signature proves at the gate: this token was produced by someone holding the issuing key, and it has not been altered since. It proves nothing about whether the token has been presented before. A signature check is a pure function of the bytes, and the same bytes give the same answer at nine in the morning and at nine at night, at gate A and at gate B, on the original phone and on the screenshot a friend received over a messaging app. Verification is stateless by design, and a stateless check cannot count.
The turnstile invariant
The property gateless entry actually needs is a property about state, and it can be stated in one sentence. Each ticket identifier moves from unspent to spent exactly once, that transition is atomic at the point of admission, and no scanner decides admission on its own. I think of it as the turnstile invariant, because a mechanical turnstile has it built in: it turns once per token and it cannot be argued with. A design either satisfies the invariant or accepts replays. There is no partial credit, and in particular there is no amount of cryptography that substitutes for it.
In a relational database the invariant is one statement. The scanner sends the token, the server verifies the signature, and then the server runs a conditional update.
UPDATE tickets
SET status = 'spent', spent_at = now(), spent_gate = $2
WHERE id = $1 AND status = 'unspent';
If one row was updated, the visitor is admitted. If zero rows were updated, the ticket was already spent, and the server can say exactly when and where, because the row remembers. Two gates that scan the same ticket 200 milliseconds apart both send their update; the database serialises them on the row, the first one wins, and the second one sees zero rows. No lock is taken explicitly, no cache is consulted, no scanner had an opinion. The row is the turnstile.
Where the spend can live
Once you see the invariant, every ticketing architecture sorts itself by where it puts the transition. On the scanner: the scanner keeps a list of tokens it has seen. That stops the same phone being scanned twice at the same gate and nothing else; the screenshot walks to the next gate and is new there. In a cache in front of the database: faster, and now two gates can race the cache and both win before the write lands. On the server, in the row: one conditional write, the invariant holds, and the cost is a network round trip per scan.
The bottom row is the one that surprises people, because the right-hand cell looks safe. The scanner is online, it asks the server, the server verifies the signature and says yes. But if the server only verifies, it says yes twice. Being online is necessary for the invariant and not sufficient. The write is what counts, and it has to be conditional on the current state of the row, in the same statement, so that no gap exists between reading "unspent" and writing "spent".
Rotating codes are a mitigation
The obvious objection is that big wallets do not show static codes. Google Wallet's rotating barcode regenerates the code on a timer using a TOTP value, with a periodMillis of 30,000 in the documented example and TOTP_SHA1 as the algorithm, so that a screenshot is stale within a period. That is a real improvement, and it is a mitigation, not the mechanism. A rotating code shrinks the replay window from the life of the ticket to thirty seconds. Two phones showing the same live code within the same thirty seconds still both pass a stateless check. The rotation makes the attacker work harder; the row is what makes the attacker fail.
Rotation earns its place in exactly one situation: the scanner is offline, so the row cannot be reached, and you would rather bound the replay window than refuse everyone. That is the top-left cell of the matrix with a time limit attached, and it should be called what it is, a degraded mode that accepts some replays until it resyncs. Writing that sentence into the design document is more useful than any amount of signature ceremony.
What gateless means for the write
An attraction without turnstiles has no machine to stop a visitor, so the refusal is a person with a phone, and the latency budget is however long a visitor will stand there before walking through. In practice that is about a second, and most of it is spent on the network. The conditional update itself is a single indexed row change, and the database does that in a millisecond. So the design puts the scanner in front of a small API, the API in front of the database, and nothing else in the path: no queue, no cache, no batch reconciliation. A scan is one request and one row.
The reply carries the reason, because a refusal at a gate is a conversation. "Already used at gate A at 09:41" lets the person with the phone decide what to do with the person in front of them. "Invalid" does not. The row knows the answer, so the API should say it.
The same row is also the analytics. Because every admission is one conditional write with a gate and a timestamp, the live visitor count, the arrivals per hour and the busiest gate are all queries over the tickets table, with no separate event stream to keep in sync. A design that had put the spend on the scanner would have needed a second pipeline to collect what the scanners saw, and that pipeline would have been the first thing to drift from the truth.
When I look back at the system, the signature, the encoding, the expiry and the rotation were all details, and the details were fine. The security argument was one conditional update, and the decision that mattered was that nothing in the building except that row was allowed to say yes.
Get new posts by email
Occasional essays on engineering, AI, and building for the people technology leaves behind.
Subscribe with RSS