
Project Story
QR window stickers drivers claim with a scan, and a sign-in that survives email scanners
For MINI Takes the States 2026, Octagon needed a window sticker every driver could scan to claim a profile for their car, printed in batches before anyone owned one. We built the code registry behind those stickers and a passwordless sign-in that corporate mail filters can't use up. Later we added a most-viewed award that counts each viewer once without storing who they are.
- Client
- Octagon, for MINI USA
- Industry
- Automotive events
- When
- 2026
- Status
- Live since September 2026
- Next.js 16
- TypeScript
- React 19
- tRPC
- Prisma
- PostgreSQL
- Better Auth
- Amazon SES
- Resend
- qrcode
- Amazon S3
- CloudFront
- Sentry
- Vitest
A sticker printed before anyone owns it
Drivers at a MINI Takes the States stop put a window cling with a QR code in their car. The clings are printed in batches of around 1,000 ahead of the event, so when the printer runs them, none of them belong to anybody yet. The code on each sticker points at a row in a registry, and the registry decides what a scan shows.
Scanning a cling opens /c/<code>, and that page has a handful of outcomes:
- An unclaimed code shows the claim panel (“This one’s spare.”).
- A claimed code redirects to the car’s profile at
/m/<slug>. The redirect is a 307 on purpose, since a permanent one gets cached and would outlive the cling being moved to another car. - A voided code says “This one’s retired.” It’s kept apart from an unknown code, so a driver whose lost cling turns up again learns why it stopped working.
- A malformed code gets a 404 before any query runs.
We didn’t cap clings per car. Claiming one means holding the physical sticker, so a cap would mostly get in the way of a driver replacing one they lost.
The claim page works with JavaScript turned off. People reach it from a phone camera in a parking lot on event cell service, so the claim is a Server Action behind a plain form that posts natively. That turned up a trap. A no-referrer policy makes the browser send Origin: null on a native form post. Next refuses that before the Server Action runs, so the claim failed for exactly the driver it was built for. The page uses same-origin instead, which still keeps the code out of any link the driver taps next.
Octagon’s project manager generates batches himself from the admin. A batch exports as a CSV of code,url for the printer’s variable-data workflow, or as a PDF code sheet for proofing.
Why the printed address is all capitals
A QR code has two ways to store text that matter here. Byte mode spends 8 bits a character. Alphanumeric mode spends 5.5, but it has no lowercase letters. One lowercase letter anywhere drops the whole payload into byte mode.
So the printed URL is entirely uppercase, HTTPS://MEETMYMINI.US/C/R8D2W6KN4P (made up). Scheme and host don’t care about case. Paths do, so a beforeFiles rewrite in next.config.js sends /C/ to the same page as /c/. With the qrcode library at its highest error correction level, the uppercase URL encodes as a 29 by 29 grid. The same address in lowercase or mixed case needs 33 by 33. On a sticker of fixed size, fewer modules means each one prints bigger, and this one gets scanned through a car window at an angle. On the proof sheet each module prints at 1.16 mm.
The code itself is 10 characters of Crockford Base32, the 32-symbol alphabet that leaves out four letters people misread or that spell words (I, L, O, U). Ten characters is 50 bits. We sized it against a blind guessing attack: a script trying 10 codes a second for 90 days. At 8 characters it would expect 0.71 hits on live clings, which is more likely than not to find a real one. At 10 it expects 0.0007. Each character is a randomBytes byte masked to its low five bits, an exact 1/32 draw.
We also decided against a check character. An eleventh symbol would only catch typos offline, and a claim has to reach the server anyway to learn whether the code is taken. To be fair to the numbers, when we chose the length we were planning around a .com host, where the eleventh character would have cost a whole QR version. The site launched on .us, one character shorter, so there’s a spare character we never spent.
Codes are screened against a short blocklist when they’re generated, after folding the digits people read as letters (5 as S, 0 as O, 1 as I). Leaving out U doesn’t catch everything once digits stand in for letters, and a bad code in the wild means a physical recall. For the same reason, src/env.js refuses an APP_URL with a path in it. That value is uppercased into every printed code, and an uppercased path prefix would break a thousand stickers at once.
One statement per state change
The clings table carries a CHECK constraint, clings_claimed_has_car, that says a cling’s status is CLAIMED exactly when its car_id is set. Status and car can then only change in the same statement, so every write to a cling goes through one module, src/server/lib/cling.ts. Each transition is one conditional UPDATE whose WHERE holds both the state it expects and the ownership check, so there’s no gap between deciding and writing. In testing, five concurrent claims on one cling produced one winner and one audit row.
The constraint has a cost we paid on purpose. Every way out of CLAIMED nulls car_id, so after a release or a car being deleted, the row no longer knows which car it was on. A ClingEvent table records each transition, and the database trigger that voids a deleted car’s clings writes its audit row before it clears the column.
Batch filling had a race. Creating a batch and topping one up are the same operation: count the shortfall, then insert it. With no lock, a top-up sent while a batch was still being created produced 5,500 rows in a batch declared as 3,000. Those extra codes would have been exported and printed. Both paths now go through one fillToSize that holds SELECT … FOR UPDATE on the batch row across the count and the inserts.
The PDF export had its own way of hurting drivers. Rendering the largest batch straight through blocked the server for 1,359 ms, stalling anyone scanning a cling at that moment. It now yields every 16 pages, which brought the worst stall to 101 ms.
Getting it onto vinyl
Print-and-cut machines register the cut to the print within about half a millimetre to a millimetre. The border on the cling artwork sat 1.6 pt inside the cut line, so ordinary drift would clip it. We moved it to 4.5 pt and laid out everything inside it from the border, so the QR frame and printed code kept their spacing.
Sign-in links an email scanner can open
There are no passwords. A driver signs in with a link emailed to them, which lasts 15 minutes and works once. That makes the email address the account, and it made one failure total. Corporate mail filters like Outlook Safe Links and Proofpoint fetch every link in a message before the person sees it. Better Auth’s verify endpoint spent the token on that first GET, so the scanner signed in and the driver got “already used” with nothing to fall back on.
The emailed link now opens /signin/confirm, a page that says “One tap and you are in” above a Sign in button. The token is spent by the Server Action behind the button, on a POST. A scanner can fetch the page as often as it likes. We checked the order on the development server: two scanner-style GETs both returned 200 with the token intact, the button press landed on /my with a session, and opening the same link again gave INVALID_TOKEN.
We looked at the usual fixes first and wrote down why they don’t work here:
- Auto-submitting the form saves a tap, but scanners that render pages run JavaScript, so the scanner would press the button too.
- Better Auth’s
allowedAttempts: 3, the top search answer for this problem, does nothing since version 1.6.11, when tokens started being consumed atomically (advisory GHSA-hc7v-rggr-4hvx). It only logs a warning. - Sniffing
Sec-Fetch-*headers fails open. A headless Chrome scanner sends what a person sends, and Better Auth’s own fetch-metadata check skips itself when the headers are missing.
Changing your email goes through the same kind of page, with the confirmation sent to the new address, since the old inbox is usually what was lost. An address that already has an account gets the same response as any other, so the form can’t reveal who’s signed up. Gmail was also threading every sign-in email into one conversation and hiding the newest link, the only one that worked, so each message now carries its own X-Entity-Ref-ID.
To be clear, we never saw a scanner burn a real driver’s link. The issue came out of our own review while we were building the email adapter. We shipped the fix anyway because the failure would be silent, since the verify succeeds for the wrong client, and it would land on corporate Outlook, where Octagon and MINI staff read their mail. It also made the problem measurable. A scanner is now a GET on /signin/confirm with no POST behind it.
Counting unique viewers without knowing who they are
On the evening of September 30, two days before the first stop, Octagon asked for an award for the car with the most profile views. The counter and its admin page were built, tested and live on the first day of the stop.
Each viewer counts once per car, ever. A signed-in viewer is deduplicated on their account across every device. An anonymous viewer is deduplicated on a first-party cookie that lasts a year. When an anonymous viewer later signs in on the same browser, their view is claimed by the account and the count doesn’t move. The owner and admins are never counted, and if they looked at the car before signing in, that view is removed.
The cookie gets set by src/proxy.ts, Next 16’s renamed middleware, on the first page a browser loads. Our first version minted it in the view beacon’s own response, and two beacons sent before that response arrived each got a fresh ID, so one view was stored under an ID nobody held and counted again on the next visit.
The beacon is a keepalive POST that fires once the page is actually visible. It always gets an empty 204, so it can’t be used to probe which cars exist. Link unfurlers from Slack and iMessage never run it, and prefetches are refused. A server-side bot filter catches headless browsers, written narrowly enough that Cubot phones and Instagram’s in-app browser still count.
The tables hold no IP address, user agent, user ID or time of day. A view row has the car and the day of the first view in Pacific time, plus a keyed HMAC of the account or cookie. A network_key hashes the day with the viewer’s /24 (or /48 on IPv6), so judges can see when many anonymous views come from a few networks, and it can’t follow a network from one day to the next.
A determined script that throws away its cookie can still inflate the anonymous count. A limit of 120 new viewers a minute per car bounds the rate, but not the total. The signed-in column is the harder number to fake, and ties are broken on it.
A podium a person approves
Nothing is awarded automatically. The award panel on /admin/views stays locked until the event’s last day has passed in Pacific time, then proposes a top three. An admin can skip a car with a reason and the next one moves up. Approving runs in a transaction that locks the event row, recomputes the podium and inserts only if it matches what the admin saw. Anything else is refused with “the podium changed, reload”, and a repeated approval creates nothing. Each award stores a frozen snapshot of the counts, so later views can’t move it.
A revoked award stays on record. Partial unique indexes on place and car, limited to rows that haven’t been revoked, let the place be awarded again. One choice we flagged and haven’t revisited is that a revoked car is never proposed again for that event. If someone wants revocations to be reversible, that’s a new action.
Related case study
Meet My MINIQuestions about this project
HQ, a software design and development agency in Ogden, Utah, built the claimable window cling system for Meet My MINI, the car profile app Octagon ran for MINI USA's MINI Takes the States 2026 rally. Clings are printed in batches of around 1,000 before anyone owns them, each carrying a 10-character code that points at a registry entry. A driver scans an unclaimed cling to attach it to their car's profile, after which a scan redirects to that profile, and a lost cling can be voided and replaced.
An all-uppercase URL fits QR alphanumeric mode, which stores 5.5 bits per character instead of byte mode's 8. With the qrcode library at the highest error correction level, the uppercase Meet My MINI address encodes as a 29 by 29 grid against 33 by 33 in lowercase, so each module prints larger on a sticker of the same size. Because URL paths are case-sensitive, a case-insensitive rewrite in the Next.js config routes the uppercase path to the same page.
For Meet My MINI, HQ chose 10 characters of Crockford Base32, which is 50 bits. HQ modeled a script guessing 10 codes a second for 90 days. At 8 characters that attack would expect 0.71 hits on real codes, and at 10 it expects 0.0007. Ten characters also kept the full URL inside QR version 3 for the host HQ was planning around, and codes are screened against a blocklist at generation so no printed sticker spells an offensive word.
HQ's approach in Meet My MINI is to make the emailed link open a confirmation page with a single Sign in button, and to spend the single-use token only in the POST that the button sends. Mail filters such as Outlook Safe Links and Proofpoint fetch links with GET requests, so they load the page without consuming the token. HQ avoided auto-submitting the form because scanners that render pages run JavaScript, and found that Better Auth's allowedAttempts option has no effect since version 1.6.11.
For the Meet My MINI most-viewed award, HQ counted each viewer once per car, deduplicating signed-in viewers on their account and anonymous viewers on a first-party cookie set on the first page load. The database keeps keyed HMACs and the day of the first view, with no IP address or user agent. Link unfurlers, bots, the car's owner and admins are excluded, and an admin approves the final podium from a frozen snapshot after the event ends.
Let's build together
Whether you're looking to design, build, update, or refresh an existing app or website, we can help. Even if we're not a good fit, we will help point you to somebody who might be.
Contact us