GET /tickets/verify/:qrSecret requires a network round trip to this API and
a check against the chain. A gate scanner running with no connectivity (a
basement venue, a stadium with saturated wifi, a rural festival) can't make
that call. This is the offline fallback: a small, signed payload embedded
alongside the existing QR secret that a scanner can verify against a locally
cached public key, with no server involved.
- When a ticket is issued (or reconciled), the backend signs a small
payload —
{ ticketId, chainTicketId, eventId, status, exp }— with an Ed25519 private key and returns it as{ payload, kid, signature }fromGET /tickets/:ticketId/offline-token(organizer/staff only, same guard as the rest of the gate endpoints). - That token is embedded in the ticket's QR code alongside the existing
qrSecret. The two aren't the same thing:qrSecretis whatverifychecks online against the DB and the chain; the offline token is what a scanner checks against nothing but its own cached public key. - Gate scanner apps fetch
GET /tickets/offline-public-keyswhile they do have network — at setup, or once per shift — and cache the result. It returns every key id a scanner should currently accept, mapped to its PEM-encoded public key. - At the door, with no network at all, the scanner:
- decodes the QR into
{ payload, kid, signature }, - looks up
kidin its cached key map, - verifies the Ed25519 signature over
JSON.stringify(payload), - rejects the token if
payload.exphas passed.
- decodes the QR into
OfflineTokenService.verify (and its static form, OfflineTokenService.verify(token, publicKeysPem))
implements exactly that check and is the reference implementation a scanner
client should port.
An offline accept is provisional, not a substitute for reconciliation: it
proves the token was minted by this backend and hasn't expired, not that the
ticket wasn't revoked or already used by another gate five minutes ago.
Scanners should still reconcile against verify (or a batch reconciliation
endpoint) the moment they're back online.
{
"ticketId": "ab12...",
"chainTicketId": "1042",
"eventId": "ev-9",
"status": "VALID",
"exp": 1780000000
}exp is a short TTL (not the event date) — it bounds how long a scanner can
go on trusting a token it can no longer check against reconciled state. A
scanner should fetch a fresh token (or reconnect and re-verify) well before
it expires, not just at the door.
Two environment variables carry the keys:
OFFLINE_SIGNING_KEY_ID/OFFLINE_SIGNING_PRIVATE_KEY— the key currently used to sign new tokens. One key id, one private key.OFFLINE_SIGNING_PUBLIC_KEYS— a JSON map of every key id a scanner should still accept,{ "<kid>": "<PEM public key>" }. This includes retired keys, not just the current one — a token signed yesterday should keep verifying today even after you rotate, until it naturally expires viaexp.
To rotate:
- Generate a new Ed25519 keypair:
openssl genpkey -algorithm ed25519 -out new-private.pem openssl pkey -in new-private.pem -pubout -out new-public.pem
- Pick a new key id (a date-based id like
2026-02keepsOFFLINE_SIGNING_KEY_IDself-documenting). - Add the new public key to
OFFLINE_SIGNING_PUBLIC_KEYSbefore the rotation deploy, so already-cached scanners that haven't re-fetched yet don't suddenly reject a token signed with a key id they don't recognize in the other direction (a new token signed with a key their cache doesn't have yet). - Deploy with
OFFLINE_SIGNING_KEY_ID/OFFLINE_SIGNING_PRIVATE_KEYset to the new pair. New tokens now sign with the new key; old ones already handed out keep verifying via the old entry inOFFLINE_SIGNING_PUBLIC_KEYS. - Once every token signed with the old key has expired (bounded by the
token TTL — see the
expfield above), remove the old key id fromOFFLINE_SIGNING_PUBLIC_KEYS. - Redeploy scanner apps' cached keys by having them hit
GET /tickets/offline-public-keysagain — this happens automatically the next time they have network, since they're expected to refresh periodically rather than cache forever.
Rotate immediately, out of band from this schedule, if a private key is ever suspected to have leaked — a leaked signing key lets an attacker mint tickets that verify offline with no way for a disconnected scanner to know they were never issued by this backend.