30 March 2026 · 5 min read

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.

QR byte capacity by symbol version, error correction level L Horizontal bars of byte capacity: version 1 holds 17, version 5 holds 106, version 10 holds 271, version 20 holds 858 and version 40 holds 2,953. A marker at about 90 bytes shows the size of a signed ticket token, which fits inside version 5. Bytes a QR symbol can carry at level L Binary mode; a signed ticket token needs about 90 Version 1 17 Version 5 106 Version 10 271 Version 20 858 Version 40 2,953 the marker is a 90-byte signed token
Source: capacity table of ISO/IEC 18004 as reproduced by qrmake.dev; token size from RFC 8032's 64-byte signature plus a 16-byte id and an 8-byte expiry.

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.

Two gates scan the same ticket 200 milliseconds apart Three lanes: gate A, the API with its database, and gate B. Gate A scans first; the API runs a conditional update that changes one row and admits. Gate B scans 200 milliseconds later; the same update changes zero rows and the API refuses, naming the earlier gate and time. Gate A API and database Gate B scan, t = 0 ms UPDATE ... WHERE status = 'unspent' 1 row changed admit same token, t = 200 ms UPDATE ... WHERE status = 'unspent' 0 rows changed refuse: spent at gate A, 200 ms ago
Illustrative: the race the invariant is designed for, with the database row as the only arbiter.

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.

Which designs satisfy the turnstile invariant A two by two grid. Horizontal axis: scanner connectivity, offline on the left and online on the right. Vertical axis: where admission is decided, on the scanner from a signature at the bottom, in a server row at the top. Only the top right cell, online scanners with the spend in a server row, satisfies the invariant. The other three accept replays in some form. Queue until reconnect or accept now and replay across gates The invariant holds one conditional write per scan Signature only every copy verifies Signature, checked online still verifies twice Scanner connectivity: offline to online Where admission is decided: signature to server row
Illustrative: a placement of the four designs; only one cell can count.

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.

SecurityQR TicketingSystem Design
All writing

Written by Mohd Shayan

Get new posts by email

Occasional essays on engineering, AI, and building for the people technology leaves behind.

One email per new post. Unsubscribe any time.

Subscribe with RSS